Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Runtime (`src/Eftdb/`):
- `Generators/SqlBuilderHelper` — `Regclass()`, `QualifiedIdentifier()`, `QuoteIdentifier()`, `EscapeStringLiteral`, `FormatTimestamp`, command grouping, SELECT→PERFORM rewriting for idempotent scripts
- `Generators/PolicyJobSqlBuilder` — shared `alter_job` clause builder
- `Generators/CompressionSettingsSqlHelper` — `SET (timescaledb.enable_columnstore = ...)` vs legacy `timescaledb.compress` clause, changed-settings diff
- `Query/` — `EF.Functions.TimeBucket()` overloads + `Internal/` translator plugin mapping to `time_bucket(...)`; runtime-only, throw outside LINQ
- `Query/` — `EF.Functions.TimeBucket()` overloads + `Internal/` translator plugin mapping to `time_bucket(...)`; `EF.Functions.First()`/`Last()` over a `ValueTuple.Create(value, time)` selector + an `IAggregateMethodCallTranslatorPlugin` mapping to `first(...)`/`last(...)` inside a `GroupBy`; runtime-only, throw outside LINQ
- `Diagnostics/` — **single place that defines and emits provider warnings**, mirroring EF's own layering: `TimescaleDbEventId` (public) exposes stable `EventId`s (base `63000`, clear of EF/relational/Npgsql ranges) usable with `ConfigureWarnings`; `TimescaleDbLoggingDefinitions : NpgsqlLoggingDefinitions` (public, replaces the `LoggingDefinitions` service via the same replace-and-derive pattern as the validator/SQL generator) holds one cached `EventDefinitionBase` field per event; `TimescaleDbLoggerExtensions` (internal) provides one strongly-typed emitter per event, lazy-initializing definitions from `logger.Definitions` (fail-loud cast — a foreign instance means the Replace registration was lost) and dispatching through EF's pipeline (ShouldLog+Log for `ILogger`, NeedsEventData+DispatchEventData for `LogTo`/`DiagnosticSource`) so warnings reach every sink and honour Ignore/Throw. New warning = EventId + definitions field + emitter method. Never emit raw `Logger.LogWarning` — `LogTo` sinks never receive it. Design-time scaffolding warnings use `IOperationReporter` instead (correct channel for the dotnet-ef console).

Design (`src/Eftdb.Design/`):
Expand Down
91 changes: 91 additions & 0 deletions docs/query-functions/first-last.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# First and Last

The `first` and `last` aggregates in TimescaleDB return the value of a column at the smallest or largest value of another column, typically the time column. They answer questions like "what was the opening and closing price of each day" without a window function or a sub-query. The `CmdScale.EntityFrameworkCore.TimescaleDB` package translates `EF.Functions.First(...)` and `EF.Functions.Last(...)` LINQ calls inside a `GroupBy` directly to `first(value, time)` and `last(value, time)` SQL.

[See also: first](https://docs.timescale.com/api/latest/hyperfunctions/first/) and [last](https://docs.timescale.com/api/latest/hyperfunctions/last/)

## Available Overloads

Both methods are generic over the value type and the ordering type, and are available on `EF.Functions`.

| C# Method | SQL Translation |
|---|---|
| `First<TValue, TTime>(IEnumerable<(TValue Value, TTime Time)> values)` | `first(value, time)` |
| `Last<TValue, TTime>(IEnumerable<(TValue Value, TTime Time)> values)` | `last(value, time)` |

The value and the ordering column are passed together as a `ValueTuple.Create(value, time)` pair, projected from the group:

```csharp
EF.Functions.First(g.Select(r => ValueTuple.Create(r.Value, r.Timestamp)))
```

The result has the type of the value. For a nullable column use a nullable value type (for example `double?`): the result is `null` when the value of the first or last row is `null`, and for a group whose values are all `null`. A `Where` on the group that matches no row of a group also gives `null`, so use a nullable result type (for example `double?`) together with `Where`; with a non-nullable type such as `double`, materializing that group throws.

Any value type with a database mapping is supported. The ordering type can be any type PostgreSQL can order, such as `DateTime`, `DateTimeOffset`, `int` or `long`.

## Usage Patterns

### GROUP BY

Open and close values per sensor:

```csharp
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
using Microsoft.EntityFrameworkCore;

var results = await context.SensorReadings
.GroupBy(r => r.SensorId)
.Select(g => new
{
SensorId = g.Key,
Opening = EF.Functions.First(g.Select(r => ValueTuple.Create(r.Temperature, r.Timestamp))),
Closing = EF.Functions.Last(g.Select(r => ValueTuple.Create(r.Temperature, r.Timestamp)))
})
.ToListAsync();
```

### With TimeBucket

Combine with [TimeBucket](time-bucket.md) to get the first and last value per interval:

```csharp
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
using Microsoft.EntityFrameworkCore;

var results = await context.SensorReadings
.GroupBy(r => EF.Functions.TimeBucket(TimeSpan.FromHours(1), r.Timestamp))
.Select(g => new
{
Bucket = g.Key,
Opening = EF.Functions.First(g.Select(r => ValueTuple.Create(r.Temperature, r.Timestamp))),
Closing = EF.Functions.Last(g.Select(r => ValueTuple.Create(r.Temperature, r.Timestamp))),
Count = g.Count()
})
.OrderBy(r => r.Bucket)
.ToListAsync();
```

### With a Filter

A `Where` on the group becomes a `FILTER (WHERE ...)` clause on the aggregate, so rows can be excluded from `first` and `last` without affecting other aggregates in the same query:

```csharp
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
using Microsoft.EntityFrameworkCore;

var results = await context.SensorReadings
.GroupBy(r => r.SensorId)
.Select(g => new
{
SensorId = g.Key,
FirstHot = EF.Functions.First(g.Where(r => r.Temperature > 30).Select(r => ValueTuple.Create(r.Temperature, r.Timestamp))),
Count = g.Count()
})
.ToListAsync();
```

## Unsupported Shapes

`first` and `last` are defined by the ordering column, so `Distinct()` and `OrderBy(...)` inside the grouping have no meaning for them. The translator refuses them with an `InvalidOperationException` ("`EF.Functions.First does not support Distinct or OrderBy inside the grouping; the time argument already orders the rows.`") while the query is compiled, before anything is sent to the database, instead of silently ignoring the call. On an entity with a primary key EF Core drops a plain `Distinct()` as a no-op before the translator sees it, so it has no effect there. The same applies to a selector that is not a `ValueTuple.Create(value, time)` pair.

The only valid source is the grouping of a `GroupBy` (`g` in the examples above). Calling the methods on any other sequence, or on the client, is not translated and throws an `InvalidOperationException`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore.Query;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;

/// <summary>
/// Registers TimescaleDB aggregate method call translators with the EF Core query pipeline.
/// </summary>
internal sealed class TimescaleDbAggregateMethodCallTranslatorPlugin(ISqlExpressionFactory sqlExpressionFactory) : IAggregateMethodCallTranslatorPlugin
{
public IEnumerable<IAggregateMethodCallTranslator> Translators { get; } =
[
new TimescaleDbFirstLastTranslator((NpgsqlSqlExpressionFactory)sqlExpressionFactory),
];
}
63 changes: 63 additions & 0 deletions src/Eftdb/Query/Internal/TimescaleDbFirstLastTranslator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;

#pragma warning disable EF1001 // Npgsql hands a ValueTuple selector to aggregate translators only as a PgRowValueExpression.

/// <summary>
/// Translates <see cref="TimescaleDbFunctionsExtensions.First{TValue, TTime}"/> and
/// <see cref="TimescaleDbFunctionsExtensions.Last{TValue, TTime}"/> calls to the <c>first</c> and <c>last</c> SQL aggregates.
/// </summary>
internal sealed class TimescaleDbFirstLastTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory) : IAggregateMethodCallTranslator
{
private static readonly MethodInfo FirstDefinition = typeof(TimescaleDbFunctionsExtensions)
.GetMethod(nameof(TimescaleDbFunctionsExtensions.First))!.GetGenericMethodDefinition();

private static readonly MethodInfo LastDefinition = typeof(TimescaleDbFunctionsExtensions)
.GetMethod(nameof(TimescaleDbFunctionsExtensions.Last))!.GetGenericMethodDefinition();

public SqlExpression? Translate(
MethodInfo method,
EnumerableExpression source,
IReadOnlyList<SqlExpression> arguments,
IDiagnosticsLogger<DbLoggerCategory.Query> logger)
{
if (!method.IsGenericMethod || method.DeclaringType != typeof(TimescaleDbFunctionsExtensions))
{
return null;
}

MethodInfo definition = method.GetGenericMethodDefinition();
string? functionName = definition == FirstDefinition ? "first" : definition == LastDefinition ? "last" : null;

if (functionName is null || source.Selector is not PgRowValueExpression { Values.Count: 2 } row)
{
return null;
}

// Returning null here would make EF fall back to a collection subquery and fail with an unrelated error.
if (source.IsDistinct || source.Orderings.Count > 0)
{
throw new InvalidOperationException(
$"EF.Functions.{method.Name} does not support Distinct or OrderBy inside the grouping; the time argument already orders the rows.");
}

SqlExpression value = sqlExpressionFactory.ApplyDefaultTypeMapping(row.Values[0]);
SqlExpression time = sqlExpressionFactory.ApplyDefaultTypeMapping(row.Values[1]);

return sqlExpressionFactory.AggregateFunction(
functionName,
[value, time],
source,
nullable: true,
argumentsPropagateNullability: [false, false],
returnType: method.ReturnType,
typeMapping: value.TypeMapping);
}
}
22 changes: 22 additions & 0 deletions src/Eftdb/Query/TimescaleDbFunctionsExtensions.FirstLast.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query;

public static partial class TimescaleDbFunctionsExtensions
{
/// <summary>
/// Returns the value of the row with the smallest time in the group, or <see langword="null"/> when that value is null.
/// Pass the value and the ordering column as a <see cref="ValueTuple.Create{T1, T2}(T1, T2)"/> pair, for example
/// <c>EF.Functions.First(g.Select(x =&gt; ValueTuple.Create(x.Value, x.Timestamp)))</c>.
/// Translates to <c>first(value, time)</c>.
/// </summary>
public static TValue First<TValue, TTime>(this DbFunctions _, IEnumerable<(TValue Value, TTime Time)> values) => Throw<TValue>();

/// <summary>
/// Returns the value of the row with the largest time in the group, or <see langword="null"/> when that value is null.
/// Pass the value and the ordering column as a <see cref="ValueTuple.Create{T1, T2}(T1, T2)"/> pair, for example
/// <c>EF.Functions.Last(g.Select(x =&gt; ValueTuple.Create(x.Value, x.Timestamp)))</c>.
/// Translates to <c>last(value, time)</c>.
/// </summary>
public static TValue Last<TValue, TTime>(this DbFunctions _, IEnumerable<(TValue Value, TTime Time)> values) => Throw<TValue>();
}
2 changes: 2 additions & 0 deletions src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ public void ApplyServices(IServiceCollection services)
services.Replace(ServiceDescriptor.Singleton<LoggingDefinitions, TimescaleDbLoggingDefinitions>());
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IMethodCallTranslatorPlugin, TimescaleDbMethodCallTranslatorPlugin>());
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IAggregateMethodCallTranslatorPlugin, TimescaleDbAggregateMethodCallTranslatorPlugin>());
}

public void Validate(IDbContextOptions options) { }
Expand Down
3 changes: 2 additions & 1 deletion src/Eftdb/TimescaleDbServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public static IServiceCollection AddEntityFrameworkTimescaleDb(this IServiceColl
new EntityFrameworkRelationalServicesBuilder(services)
.TryAdd<IMigrationsModelDiffer, TimescaleMigrationsModelDiffer>()
.TryAdd<IConventionSetPlugin, TimescaleDbContextOptionsBuilderExtensions.TimescaleDbConventionSetPlugin>()
.TryAdd<IMethodCallTranslatorPlugin, TimescaleDbMethodCallTranslatorPlugin>();
.TryAdd<IMethodCallTranslatorPlugin, TimescaleDbMethodCallTranslatorPlugin>()
.TryAdd<IAggregateMethodCallTranslatorPlugin, TimescaleDbAggregateMethodCallTranslatorPlugin>();

return services;
}
Expand Down
126 changes: 126 additions & 0 deletions tests/Eftdb.FunctionalTests/FirstLastQueryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils;
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
using Microsoft.EntityFrameworkCore;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests;

public class FirstLastQueryTests(TimescaleQueryFixture fixture)
: FirstLastQueryTestsBase<TimescaleQueryFixture>(fixture), IClassFixture<TimescaleQueryFixture>;

public class FirstLastApacheQueryTests(TimescaleApacheQueryFixture fixture)
: FirstLastQueryTestsBase<TimescaleApacheQueryFixture>(fixture), IClassFixture<TimescaleApacheQueryFixture>;

public abstract class FirstLastQueryTestsBase<TFixture>(TFixture fixture)
where TFixture : TimescaleQueryFixtureBase
{
#region Should_Generate_First_And_Last_In_GroupBy

[Fact]
public async Task Should_Generate_First_And_Last_In_GroupBy()
{
await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext();

var results = await context.Metrics
.GroupBy(m => m.SequenceNumber / 6)
.Select(g => new
{
Bucket = g.Key,
First = EF.Functions.First(g.Select(m => ValueTuple.Create(m.Value, m.Timestamp))),
Last = EF.Functions.Last(g.Select(m => ValueTuple.Create(m.Value, m.Timestamp)))
})
.OrderBy(r => r.Bucket)
.ToListAsync();

Assert.Equal(new[] { 10.0, 70.0 }, results.Select(r => r.First));
Assert.Equal(new[] { 60.0, 120.0 }, results.Select(r => r.Last));

AssertSql(
"""
SELECT q0."Key" AS "Bucket", first(q0."Value", q0."Timestamp") AS "First", last(q0."Value", q0."Timestamp") AS "Last"
FROM (
SELECT q."Timestamp", q."Value", q."SequenceNumber" / 6 AS "Key"
FROM query_metrics AS q
) AS q0
GROUP BY q0."Key"
ORDER BY q0."Key"
""");
}

#endregion

#region Should_Generate_First_With_Filter

[Fact]
public async Task Should_Generate_First_With_Filter()
{
await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext();

var results = await context.Metrics
.GroupBy(m => m.SequenceNumber / 6)
.Select(g => new
{
Bucket = g.Key,
First = EF.Functions.First(g.Where(m => m.Value > 30).Select(m => ValueTuple.Create(m.Value, m.Timestamp))),
Count = g.Count()
})
.OrderBy(r => r.Bucket)
.ToListAsync();

Assert.Equal(new[] { 40.0, 70.0 }, results.Select(r => r.First));
Assert.Equal(new[] { 6, 6 }, results.Select(r => r.Count));

AssertSql(
"""
SELECT q0."Key" AS "Bucket", first(q0."Value", q0."Timestamp") FILTER (WHERE q0."Value" > 30.0) AS "First", count(*)::int AS "Count"
FROM (
SELECT q."Timestamp", q."Value", q."SequenceNumber" / 6 AS "Key"
FROM query_metrics AS q
) AS q0
GROUP BY q0."Key"
ORDER BY q0."Key"
""");
}

#endregion

#region Should_Generate_First_And_Last_With_TimeBucket

[Fact]
public async Task Should_Generate_First_And_Last_With_TimeBucket()
{
await using TimescaleQueryFixtureBase.QueryTestContext context = fixture.CreateContext();

TimeSpan bucket = TimeSpan.FromMinutes(5);
var results = await context.Metrics
.GroupBy(m => EF.Functions.TimeBucket(bucket, m.Timestamp))
.Select(g => new
{
Bucket = g.Key,
First = EF.Functions.First(g.Select(m => ValueTuple.Create(m.Value, m.Timestamp))),
Last = EF.Functions.Last(g.Select(m => ValueTuple.Create(m.Value, m.Timestamp)))
})
.OrderBy(r => r.Bucket)
.ToListAsync();

Assert.Equal(new[] { 10.0, 60.0, 110.0 }, results.Select(r => r.First));
Assert.Equal(new[] { 50.0, 100.0, 120.0 }, results.Select(r => r.Last));

AssertSql(
"""
@bucket='00:05:00' (DbType = Object)

SELECT q0."Key" AS "Bucket", first(q0."Value", q0."Timestamp") AS "First", last(q0."Value", q0."Timestamp") AS "Last"
FROM (
SELECT q."Timestamp", q."Value", time_bucket(@bucket, q."Timestamp") AS "Key"
FROM query_metrics AS q
) AS q0
GROUP BY q0."Key"
ORDER BY q0."Key"
""");
}

#endregion

private void AssertSql(params string[] expected)
=> fixture.TestSqlLoggerFactory.AssertBaseline(expected);
}
Loading
Loading