Skip to content

Commit 43b14e0

Browse files
feat: add EF.Functions.TimeBucket() support for time_bucket queries
Introduces EF Core LINQ support for the TimescaleDB time_bucket() SQL function via EF.Functions.TimeBucket().
1 parent b3cb61e commit 43b14e0

17 files changed

Lines changed: 1158 additions & 2 deletions

.claude/reference/architecture.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ All inherit `MigrationOperation` and contain feature-specific properties:
8787
- `CreateContinuousAggregateOperation.cs` / `AlterContinuousAggregateOperation.cs` / `DropContinuousAggregateOperation.cs`
8888
- `AddContinuousAggregatePolicyOperation.cs` / `RemoveContinuousAggregatePolicyOperation.cs`
8989

90+
### Query/ - EF.Functions Extensions and LINQ Translators
91+
92+
Provides `EF.Functions` extension methods that translate to TimescaleDB SQL functions at query time.
93+
These are runtime-only — they have no in-memory implementation and throw when called outside LINQ.
94+
95+
| File | Purpose |
96+
|------|---------|
97+
| `TimescaleDbFunctionsExtensions.cs` | Partial class entry point; defines the `Throw<T>()` helper |
98+
| `TimescaleDbFunctionsExtensions.TimeBucket.cs` | 10 `TimeBucket()` overloads covering `DateTime`, `DateTimeOffset`, `DateOnly`, `int`, `long` |
99+
| `Internal/TimescaleDbMethodCallTranslatorPlugin.cs` | `IMethodCallTranslatorPlugin` — registers all translators with EF Core's query pipeline |
100+
| `Internal/TimescaleDbTimeBucketTranslator.cs` | `IMethodCallTranslator` — maps each `TimeBucket` overload to `time_bucket(...)` SQL |
101+
102+
The plugin is registered in `TimescaleDbServiceCollectionExtensions.AddEntityFrameworkTimescaleDb()` via `.TryAdd<IMethodCallTranslatorPlugin, TimescaleDbMethodCallTranslatorPlugin>()`.
103+
90104
### Generators/ - SQL and C# Code Generation
91105

92106
| File | Purpose |

.claude/reference/file-organization.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
7575
| `Operations/AddContinuousAggregatePolicyOperation.cs` | Migration operation |
7676
| `Operations/RemoveContinuousAggregatePolicyOperation.cs` | Migration operation |
7777

78+
### Query Functions
79+
80+
| File | Purpose |
81+
|------|---------|
82+
| `Query/TimescaleDbFunctionsExtensions.cs` | EF.Functions extension entry point (partial class stub) |
83+
| `Query/TimescaleDbFunctionsExtensions.TimeBucket.cs` | `EF.Functions.TimeBucket()` overloads |
84+
| `Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs` | Registers method call translators with EF Core |
85+
| `Query/Internal/TimescaleDbTimeBucketTranslator.cs` | Translates `TimeBucket` calls to `time_bucket` SQL |
86+
7887
### Coordination & Utilities
7988

8089
| File | Purpose |
@@ -137,6 +146,8 @@ src/
137146
│ │ ├── Hypertables/
138147
│ │ └── ReorderPolicies/
139148
│ ├── Operations/ # Migration operations
149+
│ ├── Query/ # EF.Functions extensions and LINQ translators
150+
│ │ └── Internal/ # EF Core query pipeline integration
140151
│ └── *.cs # Entry points, extensions
141152
142153
└── Eftdb.Design/ # Design-time library (CmdScale.EntityFrameworkCore.TimescaleDB.Design)

codecov.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ ignore:
2323
- "**/WhereClauseEpressionVisitor.cs"
2424
- "**/TimescaleDBDesignTimeServices.cs"
2525
- "**/TimescaleDbServiceCollectionExtensions.cs"
26+
- "**/TimescaleDbFunctionsExtensions.cs"
27+
- "**/TimescaleDbFunctionsExtensions.TimeBucket.cs"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"label": "Query Functions",
3+
"position": 4,
4+
"link": {
5+
"type": "generated-index",
6+
"description": "TimescaleDB functions available in LINQ queries through EF.Functions."
7+
}
8+
}
9+
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# TimeBucket
2+
3+
The `time_bucket` function in TimescaleDB partitions time-series rows into fixed-width intervals, enabling efficient `GROUP BY`, `SELECT`, and `ORDER BY` operations over time. The `CmdScale.EntityFrameworkCore.TimescaleDB` package translates `EF.Functions.TimeBucket(...)` LINQ calls directly to `time_bucket(...)` SQL, so no raw SQL strings are required in application queries.
4+
5+
[See also: time_bucket](https://docs.timescale.com/api/latest/hyperfunctions/time_bucket/)
6+
7+
## Available Overloads
8+
9+
The following overloads are available on `EF.Functions`. Each maps to the corresponding `time_bucket` SQL signature.
10+
11+
| C# Overload | SQL Translation |
12+
|---|---|
13+
| `TimeBucket(TimeSpan bucket, DateTime timestamp)` | `time_bucket(interval, timestamp)` |
14+
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp)` | `time_bucket(interval, timestamptz)` |
15+
| `TimeBucket(TimeSpan bucket, DateOnly date)` | `time_bucket(interval, date)` |
16+
| `TimeBucket(TimeSpan bucket, DateTime timestamp, TimeSpan offset)` | `time_bucket(interval, timestamp, offset)` |
17+
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp, TimeSpan offset)` | `time_bucket(interval, timestamptz, offset)` |
18+
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp, string timezone)` | `time_bucket(interval, timestamptz, timezone)` |
19+
| `TimeBucket(int bucket, int value)` | `time_bucket(integer, integer)` |
20+
| `TimeBucket(int bucket, int value, int offset)` | `time_bucket(integer, integer, offset)` |
21+
| `TimeBucket(long bucket, long value)` | `time_bucket(bigint, bigint)` |
22+
| `TimeBucket(long bucket, long value, long offset)` | `time_bucket(bigint, bigint, offset)` |
23+
24+
## Usage Patterns
25+
26+
### SELECT Projection
27+
28+
Project each row into its bucket boundary:
29+
30+
```csharp
31+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
32+
using Microsoft.EntityFrameworkCore;
33+
34+
List<DateTime> buckets = await context.Metrics
35+
.Select(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
36+
.Distinct()
37+
.ToListAsync();
38+
```
39+
40+
### GROUP BY with Aggregation
41+
42+
Group rows into time buckets and compute aggregate values:
43+
44+
```csharp
45+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
46+
using Microsoft.EntityFrameworkCore;
47+
48+
var results = await context.Metrics
49+
.GroupBy(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
50+
.Select(g => new
51+
{
52+
Bucket = g.Key,
53+
Total = g.Sum(m => m.Value),
54+
Count = g.Count()
55+
})
56+
.OrderBy(r => r.Bucket)
57+
.ToListAsync();
58+
```
59+
60+
### WHERE Filtering
61+
62+
Filter rows based on their computed bucket:
63+
64+
```csharp
65+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
66+
using Microsoft.EntityFrameworkCore;
67+
68+
TimeSpan bucket = TimeSpan.FromHours(1);
69+
DateTime threshold = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
70+
71+
List<Metric> recent = await context.Metrics
72+
.Where(m => EF.Functions.TimeBucket(bucket, m.Timestamp) >= threshold)
73+
.ToListAsync();
74+
```
75+
76+
### ORDER BY
77+
78+
Sort rows by their bucket boundary:
79+
80+
```csharp
81+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
82+
using Microsoft.EntityFrameworkCore;
83+
84+
List<Metric> ordered = await context.Metrics
85+
.OrderBy(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
86+
.ToListAsync();
87+
```
88+
89+
### With Offset
90+
91+
Shift bucket boundaries by a fixed duration. Useful when the natural bucket origin (midnight UTC) does not align with business hours:
92+
93+
```csharp
94+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
95+
using Microsoft.EntityFrameworkCore;
96+
97+
// Buckets start at :01, :06, :11, ... instead of :00, :05, :10, ...
98+
var results = await context.Metrics
99+
.Select(m => EF.Functions.TimeBucket(
100+
TimeSpan.FromMinutes(5),
101+
m.Timestamp,
102+
TimeSpan.FromMinutes(1)))
103+
.Distinct()
104+
.ToListAsync();
105+
```
106+
107+
### With Timezone
108+
109+
For `DateTimeOffset` columns, specify a timezone name to align bucket boundaries to local time zone rules, including daylight saving time transitions:
110+
111+
```csharp
112+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
113+
using Microsoft.EntityFrameworkCore;
114+
115+
var results = await context.Events
116+
.GroupBy(e => EF.Functions.TimeBucket(
117+
TimeSpan.FromHours(1),
118+
e.Timestamp,
119+
"Europe/Berlin"))
120+
.Select(g => new
121+
{
122+
Bucket = g.Key,
123+
Count = g.Count()
124+
})
125+
.ToListAsync();
126+
```
127+
128+
### Integer Bucketing
129+
130+
For hypertables using integer or bigint time columns, bucket by a fixed numeric width:
131+
132+
```csharp
133+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
134+
using Microsoft.EntityFrameworkCore;
135+
136+
// Group sequence numbers into buckets of 5
137+
var results = await context.Metrics
138+
.GroupBy(m => EF.Functions.TimeBucket(5, m.SequenceNumber))
139+
.Select(g => new
140+
{
141+
Bucket = g.Key,
142+
Count = g.Count()
143+
})
144+
.OrderBy(r => r.Bucket)
145+
.ToListAsync();
146+
```
147+
148+
The `long` variants work identically for `bigint` columns:
149+
150+
```csharp
151+
var results = await context.Metrics
152+
.GroupBy(m => EF.Functions.TimeBucket(1000L, m.EpochMilliseconds))
153+
.Select(g => new { Bucket = g.Key, Count = g.Count() })
154+
.ToListAsync();
155+
```
156+
157+
## Complete Example
158+
159+
The following example demonstrates a complete setup including entity model, `DbContext` configuration, and a GROUP BY aggregation query using `EF.Functions.TimeBucket`.
160+
161+
```csharp
162+
using CmdScale.EntityFrameworkCore.TimescaleDB.Query;
163+
using Microsoft.EntityFrameworkCore;
164+
165+
// Entity model
166+
public class SensorReading
167+
{
168+
public Guid Id { get; set; }
169+
public DateTime Timestamp { get; set; }
170+
public string SensorId { get; set; } = string.Empty;
171+
public double Temperature { get; set; }
172+
}
173+
174+
// DbContext
175+
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
176+
{
177+
public DbSet<SensorReading> SensorReadings => Set<SensorReading>();
178+
179+
protected override void OnModelCreating(ModelBuilder modelBuilder)
180+
{
181+
modelBuilder.Entity<SensorReading>(entity =>
182+
{
183+
entity.HasKey(x => new { x.Id, x.Timestamp });
184+
entity.IsHypertable(x => x.Timestamp)
185+
.WithChunkTimeInterval("1 day");
186+
});
187+
}
188+
}
189+
190+
// Registration
191+
builder.Services.AddDbContext<AppDbContext>(options =>
192+
options.UseNpgsql(connectionString).UseTimescaleDb());
193+
194+
// Query: hourly averages per sensor over the last 24 hours
195+
DateTime since = DateTime.UtcNow.AddHours(-24);
196+
197+
var hourlyAverages = await context.SensorReadings
198+
.Where(r => r.Timestamp >= since)
199+
.GroupBy(r => new
200+
{
201+
Bucket = EF.Functions.TimeBucket(TimeSpan.FromHours(1), r.Timestamp),
202+
r.SensorId
203+
})
204+
.Select(g => new
205+
{
206+
g.Key.Bucket,
207+
g.Key.SensorId,
208+
AvgTemperature = g.Average(r => r.Temperature),
209+
ReadingCount = g.Count()
210+
})
211+
.OrderBy(r => r.Bucket)
212+
.ThenBy(r => r.SensorId)
213+
.ToListAsync();
214+
```

src/Eftdb/Eftdb.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
<PackagePath>\</PackagePath>
3838
</None>
3939
</ItemGroup>
40+
<ItemGroup>
41+
<InternalsVisibleTo Include="CmdScale.EntityFrameworkCore.TimescaleDB.Tests" />
42+
</ItemGroup>
4043
<ItemGroup>
4144
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
4245
</ItemGroup>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Microsoft.EntityFrameworkCore.Query;
2+
3+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;
4+
5+
/// <summary>
6+
/// Registers TimescaleDB method call translators with the EF Core query pipeline.
7+
/// </summary>
8+
internal sealed class TimescaleDbMethodCallTranslatorPlugin(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslatorPlugin
9+
{
10+
public IEnumerable<IMethodCallTranslator> Translators { get; } =
11+
[
12+
new TimescaleDbTimeBucketTranslator(sqlExpressionFactory),
13+
];
14+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.Reflection;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Diagnostics;
4+
using Microsoft.EntityFrameworkCore.Query;
5+
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
6+
7+
namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;
8+
9+
/// <summary>
10+
/// Translates <see cref="TimescaleDbFunctionsExtensions.TimeBucket"/> calls to <c>time_bucket</c> SQL function.
11+
/// </summary>
12+
internal sealed class TimescaleDbTimeBucketTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator
13+
{
14+
private static readonly MethodInfo[] TimeBucketMethods =
15+
[
16+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime)])!,
17+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset)])!,
18+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateOnly)])!,
19+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime), typeof(TimeSpan)])!,
20+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(TimeSpan)])!,
21+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(string)])!,
22+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int)])!,
23+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int), typeof(int)])!,
24+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long)])!,
25+
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long), typeof(long)])!,
26+
];
27+
28+
private static readonly bool[][] PropagateNullability =
29+
[
30+
[],
31+
[true],
32+
[true, true],
33+
[true, true, true],
34+
];
35+
36+
public SqlExpression? Translate(
37+
SqlExpression? instance,
38+
MethodInfo method,
39+
IReadOnlyList<SqlExpression> arguments,
40+
IDiagnosticsLogger<DbLoggerCategory.Query> logger)
41+
{
42+
if (!TimeBucketMethods.Contains(method))
43+
{
44+
return null;
45+
}
46+
47+
// Skip the DbFunctions parameter — not passed to SQL
48+
IReadOnlyList<SqlExpression> functionArguments = arguments.Skip(1).ToList();
49+
50+
return sqlExpressionFactory.Function(
51+
"time_bucket",
52+
functionArguments,
53+
nullable: true,
54+
argumentsPropagateNullability: PropagateNullability[functionArguments.Count],
55+
returnType: method.ReturnType,
56+
typeMapping: functionArguments[1].TypeMapping);
57+
}
58+
}

0 commit comments

Comments
 (0)