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
14 changes: 14 additions & 0 deletions .claude/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ All inherit `MigrationOperation` and contain feature-specific properties:
- `CreateContinuousAggregateOperation.cs` / `AlterContinuousAggregateOperation.cs` / `DropContinuousAggregateOperation.cs`
- `AddContinuousAggregatePolicyOperation.cs` / `RemoveContinuousAggregatePolicyOperation.cs`

### Query/ - EF.Functions Extensions and LINQ Translators

Provides `EF.Functions` extension methods that translate to TimescaleDB SQL functions at query time.
These are runtime-only — they have no in-memory implementation and throw when called outside LINQ.

| File | Purpose |
|------|---------|
| `TimescaleDbFunctionsExtensions.cs` | Partial class entry point; defines the `Throw<T>()` helper |
| `TimescaleDbFunctionsExtensions.TimeBucket.cs` | 10 `TimeBucket()` overloads covering `DateTime`, `DateTimeOffset`, `DateOnly`, `int`, `long` |
| `Internal/TimescaleDbMethodCallTranslatorPlugin.cs` | `IMethodCallTranslatorPlugin` — registers all translators with EF Core's query pipeline |
| `Internal/TimescaleDbTimeBucketTranslator.cs` | `IMethodCallTranslator` — maps each `TimeBucket` overload to `time_bucket(...)` SQL |

The plugin is registered in `TimescaleDbServiceCollectionExtensions.AddEntityFrameworkTimescaleDb()` via `.TryAdd<IMethodCallTranslatorPlugin, TimescaleDbMethodCallTranslatorPlugin>()`.

### Generators/ - SQL and C# Code Generation

| File | Purpose |
Expand Down
11 changes: 11 additions & 0 deletions .claude/reference/file-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ Quick reference for locating key files in the CmdScale.EntityFrameworkCore.Times
| `Operations/AddContinuousAggregatePolicyOperation.cs` | Migration operation |
| `Operations/RemoveContinuousAggregatePolicyOperation.cs` | Migration operation |

### Query Functions

| File | Purpose |
|------|---------|
| `Query/TimescaleDbFunctionsExtensions.cs` | EF.Functions extension entry point (partial class stub) |
| `Query/TimescaleDbFunctionsExtensions.TimeBucket.cs` | `EF.Functions.TimeBucket()` overloads |
| `Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs` | Registers method call translators with EF Core |
| `Query/Internal/TimescaleDbTimeBucketTranslator.cs` | Translates `TimeBucket` calls to `time_bucket` SQL |

### Coordination & Utilities

| File | Purpose |
Expand Down Expand Up @@ -137,6 +146,8 @@ src/
│ │ ├── Hypertables/
│ │ └── ReorderPolicies/
│ ├── Operations/ # Migration operations
│ ├── Query/ # EF.Functions extensions and LINQ translators
│ │ └── Internal/ # EF Core query pipeline integration
│ └── *.cs # Entry points, extensions
└── Eftdb.Design/ # Design-time library (CmdScale.EntityFrameworkCore.TimescaleDB.Design)
Expand Down
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ ignore:
- "**/WhereClauseEpressionVisitor.cs"
- "**/TimescaleDBDesignTimeServices.cs"
- "**/TimescaleDbServiceCollectionExtensions.cs"
- "**/TimescaleDbFunctionsExtensions.cs"
- "**/TimescaleDbFunctionsExtensions.TimeBucket.cs"
9 changes: 9 additions & 0 deletions docs/query-functions/_category.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"label": "Query Functions",
"position": 4,
"link": {
"type": "generated-index",
"description": "TimescaleDB functions available in LINQ queries through EF.Functions."
}
}

214 changes: 214 additions & 0 deletions docs/query-functions/time-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# TimeBucket

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.

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

## Available Overloads

The following overloads are available on `EF.Functions`. Each maps to the corresponding `time_bucket` SQL signature.

| C# Overload | SQL Translation |
|---|---|
| `TimeBucket(TimeSpan bucket, DateTime timestamp)` | `time_bucket(interval, timestamp)` |
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp)` | `time_bucket(interval, timestamptz)` |
| `TimeBucket(TimeSpan bucket, DateOnly date)` | `time_bucket(interval, date)` |
| `TimeBucket(TimeSpan bucket, DateTime timestamp, TimeSpan offset)` | `time_bucket(interval, timestamp, offset)` |
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp, TimeSpan offset)` | `time_bucket(interval, timestamptz, offset)` |
| `TimeBucket(TimeSpan bucket, DateTimeOffset timestamp, string timezone)` | `time_bucket(interval, timestamptz, timezone)` |
| `TimeBucket(int bucket, int value)` | `time_bucket(integer, integer)` |
| `TimeBucket(int bucket, int value, int offset)` | `time_bucket(integer, integer, offset)` |
| `TimeBucket(long bucket, long value)` | `time_bucket(bigint, bigint)` |
| `TimeBucket(long bucket, long value, long offset)` | `time_bucket(bigint, bigint, offset)` |

## Usage Patterns

### SELECT Projection

Project each row into its bucket boundary:

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

List<DateTime> buckets = await context.Metrics
.Select(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
.Distinct()
.ToListAsync();
```

### GROUP BY with Aggregation

Group rows into time buckets and compute aggregate values:

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

var results = await context.Metrics
.GroupBy(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
.Select(g => new
{
Bucket = g.Key,
Total = g.Sum(m => m.Value),
Count = g.Count()
})
.OrderBy(r => r.Bucket)
.ToListAsync();
```

### WHERE Filtering

Filter rows based on their computed bucket:

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

TimeSpan bucket = TimeSpan.FromHours(1);
DateTime threshold = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);

List<Metric> recent = await context.Metrics
.Where(m => EF.Functions.TimeBucket(bucket, m.Timestamp) >= threshold)
.ToListAsync();
```

### ORDER BY

Sort rows by their bucket boundary:

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

List<Metric> ordered = await context.Metrics
.OrderBy(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp))
.ToListAsync();
```

### With Offset

Shift bucket boundaries by a fixed duration. Useful when the natural bucket origin (midnight UTC) does not align with business hours:

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

// Buckets start at :01, :06, :11, ... instead of :00, :05, :10, ...
var results = await context.Metrics
.Select(m => EF.Functions.TimeBucket(
TimeSpan.FromMinutes(5),
m.Timestamp,
TimeSpan.FromMinutes(1)))
.Distinct()
.ToListAsync();
```

### With Timezone

For `DateTimeOffset` columns, specify a timezone name to align bucket boundaries to local time zone rules, including daylight saving time transitions:

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

var results = await context.Events
.GroupBy(e => EF.Functions.TimeBucket(
TimeSpan.FromHours(1),
e.Timestamp,
"Europe/Berlin"))
.Select(g => new
{
Bucket = g.Key,
Count = g.Count()
})
.ToListAsync();
```

### Integer Bucketing

For hypertables using integer or bigint time columns, bucket by a fixed numeric width:

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

// Group sequence numbers into buckets of 5
var results = await context.Metrics
.GroupBy(m => EF.Functions.TimeBucket(5, m.SequenceNumber))
.Select(g => new
{
Bucket = g.Key,
Count = g.Count()
})
.OrderBy(r => r.Bucket)
.ToListAsync();
```

The `long` variants work identically for `bigint` columns:

```csharp
var results = await context.Metrics
.GroupBy(m => EF.Functions.TimeBucket(1000L, m.EpochMilliseconds))
.Select(g => new { Bucket = g.Key, Count = g.Count() })
.ToListAsync();
```

## Complete Example

The following example demonstrates a complete setup including entity model, `DbContext` configuration, and a GROUP BY aggregation query using `EF.Functions.TimeBucket`.

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

// Entity model
public class SensorReading
{
public Guid Id { get; set; }
public DateTime Timestamp { get; set; }
public string SensorId { get; set; } = string.Empty;
public double Temperature { get; set; }
}

// DbContext
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<SensorReading> SensorReadings => Set<SensorReading>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<SensorReading>(entity =>
{
entity.HasKey(x => new { x.Id, x.Timestamp });
entity.IsHypertable(x => x.Timestamp)
.WithChunkTimeInterval("1 day");
});
}
}

// Registration
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString).UseTimescaleDb());

// Query: hourly averages per sensor over the last 24 hours
DateTime since = DateTime.UtcNow.AddHours(-24);

var hourlyAverages = await context.SensorReadings
.Where(r => r.Timestamp >= since)
.GroupBy(r => new
{
Bucket = EF.Functions.TimeBucket(TimeSpan.FromHours(1), r.Timestamp),
r.SensorId
})
.Select(g => new
{
g.Key.Bucket,
g.Key.SensorId,
AvgTemperature = g.Average(r => r.Temperature),
ReadingCount = g.Count()
})
.OrderBy(r => r.Bucket)
.ThenBy(r => r.SensorId)
.ToListAsync();
```
3 changes: 3 additions & 0 deletions src/Eftdb/Eftdb.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="CmdScale.EntityFrameworkCore.TimescaleDB.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
Expand Down
14 changes: 14 additions & 0 deletions src/Eftdb/Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore.Query;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;

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

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal;

/// <summary>
/// Translates <see cref="TimescaleDbFunctionsExtensions.TimeBucket"/> calls to <c>time_bucket</c> SQL function.
/// </summary>
internal sealed class TimescaleDbTimeBucketTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator
{
private static readonly MethodInfo[] TimeBucketMethods =
[
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateOnly)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime), typeof(TimeSpan)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(TimeSpan)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(string)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int), typeof(int)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long)])!,
typeof(TimescaleDbFunctionsExtensions).GetMethod(nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long), typeof(long)])!,
];

private static readonly bool[][] PropagateNullability =
[
[],
[true],
[true, true],
[true, true, true],
];

public SqlExpression? Translate(
SqlExpression? instance,
MethodInfo method,
IReadOnlyList<SqlExpression> arguments,
IDiagnosticsLogger<DbLoggerCategory.Query> logger)
{
if (!TimeBucketMethods.Contains(method))
{
return null;
}

// Skip the DbFunctions parameter — not passed to SQL
IReadOnlyList<SqlExpression> functionArguments = arguments.Skip(1).ToList();

return sqlExpressionFactory.Function(
"time_bucket",
functionArguments,
nullable: true,
argumentsPropagateNullability: PropagateNullability[functionArguments.Count],
returnType: method.ReturnType,
typeMapping: functionArguments[1].TypeMapping);
}
}
Loading