diff --git a/.claude/reference/architecture.md b/.claude/reference/architecture.md index c8d9cde..303bd6e 100644 --- a/.claude/reference/architecture.md +++ b/.claude/reference/architecture.md @@ -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()` 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()`. + ### Generators/ - SQL and C# Code Generation | File | Purpose | diff --git a/.claude/reference/file-organization.md b/.claude/reference/file-organization.md index 8ea99f0..5d92cd3 100644 --- a/.claude/reference/file-organization.md +++ b/.claude/reference/file-organization.md @@ -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 | @@ -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) diff --git a/codecov.yml b/codecov.yml index 585ef73..a88b098 100644 --- a/codecov.yml +++ b/codecov.yml @@ -23,3 +23,5 @@ ignore: - "**/WhereClauseEpressionVisitor.cs" - "**/TimescaleDBDesignTimeServices.cs" - "**/TimescaleDbServiceCollectionExtensions.cs" + - "**/TimescaleDbFunctionsExtensions.cs" + - "**/TimescaleDbFunctionsExtensions.TimeBucket.cs" diff --git a/docs/query-functions/_category.json b/docs/query-functions/_category.json new file mode 100644 index 0000000..248c922 --- /dev/null +++ b/docs/query-functions/_category.json @@ -0,0 +1,9 @@ +{ + "label": "Query Functions", + "position": 4, + "link": { + "type": "generated-index", + "description": "TimescaleDB functions available in LINQ queries through EF.Functions." + } +} + diff --git a/docs/query-functions/time-bucket.md b/docs/query-functions/time-bucket.md new file mode 100644 index 0000000..5d5d303 --- /dev/null +++ b/docs/query-functions/time-bucket.md @@ -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 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 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 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 options) : DbContext(options) +{ + public DbSet SensorReadings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(x => new { x.Id, x.Timestamp }); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + }); + } +} + +// Registration +builder.Services.AddDbContext(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(); +``` diff --git a/src/Eftdb/Eftdb.csproj b/src/Eftdb/Eftdb.csproj index d7090b6..2aef2ee 100644 --- a/src/Eftdb/Eftdb.csproj +++ b/src/Eftdb/Eftdb.csproj @@ -37,6 +37,9 @@ \ + + + diff --git a/src/Eftdb/Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs b/src/Eftdb/Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs new file mode 100644 index 0000000..e44204b --- /dev/null +++ b/src/Eftdb/Query/Internal/TimescaleDbMethodCallTranslatorPlugin.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Query; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal; + +/// +/// Registers TimescaleDB method call translators with the EF Core query pipeline. +/// +internal sealed class TimescaleDbMethodCallTranslatorPlugin(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslatorPlugin +{ + public IEnumerable Translators { get; } = + [ + new TimescaleDbTimeBucketTranslator(sqlExpressionFactory), + ]; +} diff --git a/src/Eftdb/Query/Internal/TimescaleDbTimeBucketTranslator.cs b/src/Eftdb/Query/Internal/TimescaleDbTimeBucketTranslator.cs new file mode 100644 index 0000000..efbd660 --- /dev/null +++ b/src/Eftdb/Query/Internal/TimescaleDbTimeBucketTranslator.cs @@ -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; + +/// +/// Translates calls to time_bucket SQL function. +/// +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 arguments, + IDiagnosticsLogger logger) + { + if (!TimeBucketMethods.Contains(method)) + { + return null; + } + + // Skip the DbFunctions parameter — not passed to SQL + IReadOnlyList functionArguments = arguments.Skip(1).ToList(); + + return sqlExpressionFactory.Function( + "time_bucket", + functionArguments, + nullable: true, + argumentsPropagateNullability: PropagateNullability[functionArguments.Count], + returnType: method.ReturnType, + typeMapping: functionArguments[1].TypeMapping); + } +} diff --git a/src/Eftdb/Query/TimescaleDbFunctionsExtensions.TimeBucket.cs b/src/Eftdb/Query/TimescaleDbFunctionsExtensions.TimeBucket.cs new file mode 100644 index 0000000..bbbb268 --- /dev/null +++ b/src/Eftdb/Query/TimescaleDbFunctionsExtensions.TimeBucket.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query; + +public static partial class TimescaleDbFunctionsExtensions +{ + /// + /// Buckets a timestamp into the specified interval. + /// Translates to time_bucket(interval, timestamp). + /// + public static DateTime TimeBucket(this DbFunctions _, TimeSpan bucket, DateTime timestamp) => Throw(); + + /// + /// Buckets a timestamp with time zone into the specified interval. + /// Translates to time_bucket(interval, timestamptz). + /// + public static DateTimeOffset TimeBucket(this DbFunctions _, TimeSpan bucket, DateTimeOffset timestamp) => Throw(); + + /// + /// Buckets a date into the specified interval. + /// Translates to time_bucket(interval, date). + /// + public static DateOnly TimeBucket(this DbFunctions _, TimeSpan bucket, DateOnly date) => Throw(); + + /// + /// Buckets a timestamp into the specified interval with an offset. + /// Translates to time_bucket(interval, timestamp, offset). + /// + public static DateTime TimeBucket(this DbFunctions _, TimeSpan bucket, DateTime timestamp, TimeSpan offset) => Throw(); + + /// + /// Buckets a timestamp with time zone into the specified interval with an offset. + /// Translates to time_bucket(interval, timestamptz, offset). + /// + public static DateTimeOffset TimeBucket(this DbFunctions _, TimeSpan bucket, DateTimeOffset timestamp, TimeSpan offset) => Throw(); + + /// + /// Buckets a timestamp with time zone into the specified interval in the given timezone. + /// Translates to time_bucket(interval, timestamptz, timezone). + /// + public static DateTimeOffset TimeBucket(this DbFunctions _, TimeSpan bucket, DateTimeOffset timestamp, string timezone) => Throw(); + + /// + /// Buckets an integer value into the specified width. + /// Translates to time_bucket(integer, integer). + /// + public static int TimeBucket(this DbFunctions _, int bucket, int value) => Throw(); + + /// + /// Buckets an integer value into the specified width with an offset. + /// Translates to time_bucket(integer, integer, offset). + /// + public static int TimeBucket(this DbFunctions _, int bucket, int value, int offset) => Throw(); + + /// + /// Buckets a bigint value into the specified width. + /// Translates to time_bucket(bigint, bigint). + /// + public static long TimeBucket(this DbFunctions _, long bucket, long value) => Throw(); + + /// + /// Buckets a bigint value into the specified width with an offset. + /// Translates to time_bucket(bigint, bigint, offset). + /// + public static long TimeBucket(this DbFunctions _, long bucket, long value, long offset) => Throw(); +} diff --git a/src/Eftdb/Query/TimescaleDbFunctionsExtensions.cs b/src/Eftdb/Query/TimescaleDbFunctionsExtensions.cs new file mode 100644 index 0000000..0c41c7d --- /dev/null +++ b/src/Eftdb/Query/TimescaleDbFunctionsExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Query; + +/// +/// Provides extension methods on that translate to TimescaleDB SQL functions. +/// These methods are for use with Entity Framework Core LINQ queries only and have no in-memory implementation. +/// +public static partial class TimescaleDbFunctionsExtensions +{ + private static T Throw() => + throw new InvalidOperationException( + "This method is for use with Entity Framework Core LINQ queries only and has no in-memory implementation."); +} diff --git a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs index d3350db..b56e140 100644 --- a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs +++ b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs @@ -3,11 +3,13 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -56,6 +58,8 @@ public void ApplyServices(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.Replace(ServiceDescriptor.Scoped()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); } public void Validate(IDbContextOptions options) { } diff --git a/src/Eftdb/TimescaleDbServiceCollectionExtensions.cs b/src/Eftdb/TimescaleDbServiceCollectionExtensions.cs index f5cbaf8..f761c82 100644 --- a/src/Eftdb/TimescaleDbServiceCollectionExtensions.cs +++ b/src/Eftdb/TimescaleDbServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.DependencyInjection; namespace CmdScale.EntityFrameworkCore.TimescaleDB @@ -15,7 +17,8 @@ public static IServiceCollection AddEntityFrameworkTimescaleDb(this IServiceColl { new EntityFrameworkRelationalServicesBuilder(services) .TryAdd() - .TryAdd(); + .TryAdd() + .TryAdd(); return services; } diff --git a/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs b/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs new file mode 100644 index 0000000..44ee50d --- /dev/null +++ b/tests/Eftdb.FunctionalTests/TimeBucketQueryTests.cs @@ -0,0 +1,173 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query; +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests; + +public class TimeBucketQueryTests(TimescaleQueryFixture fixture) : IClassFixture +{ + #region Should_Generate_TimeBucket_DateTime_In_Select + + [Fact] + public async Task Should_Generate_TimeBucket_DateTime_In_Select() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + TimeSpan bucket = TimeSpan.FromMinutes(5); + _ = await context.Metrics + .Select(m => EF.Functions.TimeBucket(bucket, m.Timestamp)) + .ToListAsync(); + + AssertSql( + """ + @bucket='00:05:00' (DbType = Object) + + SELECT time_bucket(@bucket, q."Timestamp") + FROM query_metrics AS q + """); + } + + #endregion + + #region Should_Generate_TimeBucket_DateTime_In_GroupBy + + [Fact] + public async Task Should_Generate_TimeBucket_DateTime_In_GroupBy() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + TimeSpan bucket = TimeSpan.FromMinutes(5); + _ = await context.Metrics + .GroupBy(m => EF.Functions.TimeBucket(bucket, m.Timestamp)) + .Select(g => new + { + Bucket = g.Key, + Total = g.Sum(m => m.Value) + }) + .ToListAsync(); + + AssertSql( + """ + @bucket='00:05:00' (DbType = Object) + + SELECT q0."Key" AS "Bucket", COALESCE(sum(q0."Value"), 0.0) AS "Total" + FROM ( + SELECT q."Value", time_bucket(@bucket, q."Timestamp") AS "Key" + FROM query_metrics AS q + ) AS q0 + GROUP BY q0."Key" + """); + } + + #endregion + + #region Should_Generate_TimeBucket_DateTime_In_Where + + [Fact] + public async Task Should_Generate_TimeBucket_DateTime_In_Where() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + TimeSpan bucket = TimeSpan.FromMinutes(5); + DateTime threshold = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + _ = await context.Metrics + .Where(m => EF.Functions.TimeBucket(bucket, m.Timestamp) == threshold) + .ToListAsync(); + + AssertSql( + """ + @bucket='00:05:00' (DbType = Object) + @threshold='2025-01-06T10:00:00.0000000Z' (DbType = DateTime) + + SELECT q."Id", q."SequenceNumber", q."Timestamp", q."Value" + FROM query_metrics AS q + WHERE time_bucket(@bucket, q."Timestamp") = @threshold + """); + } + + #endregion + + #region Should_Generate_TimeBucket_DateTime_In_OrderBy + + [Fact] + public async Task Should_Generate_TimeBucket_DateTime_In_OrderBy() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + TimeSpan bucket = TimeSpan.FromMinutes(5); + _ = await context.Metrics + .OrderBy(m => EF.Functions.TimeBucket(bucket, m.Timestamp)) + .ToListAsync(); + + AssertSql( + """ + @bucket='00:05:00' (DbType = Object) + + SELECT q."Id", q."SequenceNumber", q."Timestamp", q."Value" + FROM query_metrics AS q + ORDER BY time_bucket(@bucket, q."Timestamp") + """); + } + + #endregion + + #region Should_Generate_TimeBucket_DateTime_With_Offset + + [Fact] + public async Task Should_Generate_TimeBucket_DateTime_With_Offset() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + TimeSpan bucket = TimeSpan.FromMinutes(5); + TimeSpan offset = TimeSpan.FromMinutes(1); + _ = await context.Metrics + .Select(m => EF.Functions.TimeBucket(bucket, m.Timestamp, offset)) + .ToListAsync(); + + AssertSql( + """ + @bucket='00:05:00' (DbType = Object) + @offset='00:01:00' (DbType = Object) + + SELECT time_bucket(@bucket, q."Timestamp", @offset) + FROM query_metrics AS q + """); + } + + #endregion + + #region Should_Generate_TimeBucket_Integer_In_GroupBy + + [Fact] + public async Task Should_Generate_TimeBucket_Integer_In_GroupBy() + { + await using TimescaleQueryFixture.QueryTestContext context = fixture.CreateContext(); + + int bucket = 5; + _ = await context.Metrics + .GroupBy(m => EF.Functions.TimeBucket(bucket, m.SequenceNumber)) + .Select(g => new + { + Bucket = g.Key, + Count = g.Count() + }) + .ToListAsync(); + + AssertSql( + """ + @bucket='5' + + SELECT q0."Key" AS "Bucket", count(*)::int AS "Count" + FROM ( + SELECT time_bucket(@bucket, q."SequenceNumber") AS "Key" + FROM query_metrics AS q + ) AS q0 + GROUP BY q0."Key" + """); + } + + #endregion + + private void AssertSql(params string[] expected) + => fixture.TestSqlLoggerFactory.AssertBaseline(expected); +} diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs new file mode 100644 index 0000000..ea5bc7b --- /dev/null +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils; + +public class TimescaleQueryFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg17") + .WithDatabase("query_tests_db") + .WithUsername(TimescaleConnectionHelper.Username) + .WithPassword(TimescaleConnectionHelper.Password) + .Build(); + + public TestSqlLoggerFactory TestSqlLoggerFactory { get; } = new(); + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + await using QueryTestContext context = CreateContext(); + await context.Database.EnsureCreatedAsync(); + + DateTime baseTime = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + for (int i = 0; i < 12; i++) + { + context.Metrics.Add(new QueryMetric + { + Timestamp = baseTime.AddMinutes(i), + SequenceNumber = i, + Value = (i + 1) * 10.0 + }); + } + + await context.SaveChangesAsync(); + } + + public async Task DisposeAsync() + { + await _container.StopAsync(); + } + + public QueryTestContext CreateContext() + { + TestSqlLoggerFactory.Clear(); + return new QueryTestContext(_container.GetConnectionString(), TestSqlLoggerFactory); + } + + public class QueryMetric + { + public int Id { get; set; } + public DateTime Timestamp { get; set; } + public int SequenceNumber { get; set; } + public double Value { get; set; } + } + + public class QueryTestContext(string connectionString, TestSqlLoggerFactory loggerFactory) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql(connectionString) + .UseTimescaleDb() + .UseLoggerFactory(loggerFactory) + .EnableSensitiveDataLogging(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("query_metrics"); + entity.HasKey(x => x.Id); + }); + } + } +} diff --git a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs new file mode 100644 index 0000000..e182791 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs @@ -0,0 +1,215 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class TimeBucketIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async Task InitializeAsync() + { + _container = new PostgreSqlBuilder() + .WithImage("timescale/timescaledb:latest-pg16") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async Task DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Translate_TimeBucket_In_Select + + private class TimeBucketSelectMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimeBucketSelectContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("tb_select_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Translate_TimeBucket_In_Select() + { + // Arrange + await using TimeBucketSelectContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime baseTime = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + for (int i = 0; i < 5; i++) + { + DateTime ts = baseTime.AddMinutes(i); + await context.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO \"tb_select_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i * 10)})"); + } + + // Act + List buckets = await context.Metrics + .Select(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp)) + .Distinct() + .ToListAsync(); + + // Assert + Assert.Single(buckets); + } + + #endregion + + #region Should_Translate_TimeBucket_In_GroupBy + + private class TimeBucketGroupByMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class TimeBucketGroupByContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("tb_groupby_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Translate_TimeBucket_In_GroupBy() + { + // Arrange + await using TimeBucketGroupByContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + DateTime baseTime = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); + // Insert 12 rows: 10:00 through 10:11 + for (int i = 0; i < 12; i++) + { + DateTime ts = baseTime.AddMinutes(i); + await context.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO \"tb_groupby_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i + 1)})"); + } + + // Act — group into 5-minute buckets and sum + 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(); + + // Assert — expect 3 buckets: [10:00-10:05), [10:05-10:10), [10:10-10:15) + Assert.Equal(3, results.Count); + + // First bucket: minutes 0-4, values 1+2+3+4+5 = 15 + Assert.Equal(5, results[0].Count); + Assert.Equal(15, results[0].Total); + + // Second bucket: minutes 5-9, values 6+7+8+9+10 = 40 + Assert.Equal(5, results[1].Count); + Assert.Equal(40, results[1].Total); + + // Third bucket: minutes 10-11, values 11+12 = 23 + Assert.Equal(2, results[2].Count); + Assert.Equal(23, results[2].Total); + } + + #endregion + + #region Should_Translate_TimeBucket_With_Integer_Arguments + + private class TimeBucketIntMetric + { + public int Id { get; set; } + public int SequenceNumber { get; set; } + public double Value { get; set; } + } + + private class TimeBucketIntContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("tb_int_metrics"); + entity.HasKey(x => x.Id); + }); + } + } + + [Fact] + public async Task Should_Translate_TimeBucket_With_Integer_Arguments() + { + // Arrange + await using TimeBucketIntContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(); + + for (int i = 0; i < 20; i++) + { + await context.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO \"tb_int_metrics\" (\"Id\", \"SequenceNumber\", \"Value\") VALUES ({i + 1}, {i}, {(double)(i * 10)})"); + } + + // Act — bucket SequenceNumber into groups 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(); + + // Assert — expect 4 buckets: [0-5), [5-10), [10-15), [15-20) + Assert.Equal(4, results.Count); + Assert.All(results, r => Assert.Equal(5, r.Count)); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Query/TimescaleDbTimeBucketTranslatorTests.cs b/tests/Eftdb.Tests/Query/TimescaleDbTimeBucketTranslatorTests.cs new file mode 100644 index 0000000..db6a04a --- /dev/null +++ b/tests/Eftdb.Tests/Query/TimescaleDbTimeBucketTranslatorTests.cs @@ -0,0 +1,276 @@ +using System.Reflection; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query; +using CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; +using Moq; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Query; + +#pragma warning disable EF1001 // Internal EF Core API usage. + +public class TimescaleDbTimeBucketTranslatorTests +{ + private readonly Mock _sqlExpressionFactoryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly TimescaleDbTimeBucketTranslator _translator; + + private string? _capturedFunctionName; + private int _capturedArgumentCount; + private Type? _capturedReturnType; + private bool _capturedNullable; + private bool[]? _capturedNullability; + + public TimescaleDbTimeBucketTranslatorTests() + { + _translator = new TimescaleDbTimeBucketTranslator(_sqlExpressionFactoryMock.Object); + + _sqlExpressionFactoryMock + .Setup(f => f.Function( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, bool, IEnumerable, Type, RelationalTypeMapping?>( + (name, args, nullable, nullability, returnType, _) => + { + _capturedFunctionName = name; + _capturedArgumentCount = args.Count(); + _capturedNullable = nullable; + _capturedNullability = [.. nullability]; + _capturedReturnType = returnType; + }) + .Returns((SqlFunctionExpression)null!); + } + + private static SqlExpression CreateMockArgument() + { + Mock mock = new(typeof(int), null!); + return mock.Object; + } + + private void AssertTimeBucketTranslation(int expectedArgCount, Type expectedReturnType) + { + Assert.Equal("time_bucket", _capturedFunctionName); + Assert.Equal(expectedArgCount, _capturedArgumentCount); + Assert.True(_capturedNullable); + Assert.NotNull(_capturedNullability); + Assert.Equal(expectedArgCount, _capturedNullability!.Length); + Assert.All(_capturedNullability, n => Assert.True(n)); + Assert.Equal(expectedReturnType, _capturedReturnType); + } + + #region Should_Return_Null_For_Non_Matching_Method + + [Fact] + public void Should_Return_Null_For_Non_Matching_Method() + { + // Arrange + MethodInfo nonMatchingMethod = typeof(Math).GetMethod(nameof(Math.Abs), [typeof(int)])!; + List arguments = [CreateMockArgument(), CreateMockArgument()]; + + // Act + SqlExpression? result = _translator.Translate(null, nonMatchingMethod, arguments, _loggerMock.Object); + + // Assert + Assert.Null(result); + Assert.Null(_capturedFunctionName); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateTime + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateTime() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(2, typeof(DateTime)); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateTimeOffset + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateTimeOffset() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(2, typeof(DateTimeOffset)); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateOnly + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateOnly() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateOnly)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(2, typeof(DateOnly)); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateTime_Offset + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateTime_Offset() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTime), typeof(TimeSpan)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(3, typeof(DateTime)); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateTimeOffset_Offset + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateTimeOffset_Offset() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(TimeSpan)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(3, typeof(DateTimeOffset)); + } + + #endregion + + #region Should_Translate_TimeBucket_TimeSpan_DateTimeOffset_Timezone + + [Fact] + public void Should_Translate_TimeBucket_TimeSpan_DateTimeOffset_Timezone() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(TimeSpan), typeof(DateTimeOffset), typeof(string)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(3, typeof(DateTimeOffset)); + } + + #endregion + + #region Should_Translate_TimeBucket_Int_Int + + [Fact] + public void Should_Translate_TimeBucket_Int_Int() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(2, typeof(int)); + } + + #endregion + + #region Should_Translate_TimeBucket_Int_Int_Offset + + [Fact] + public void Should_Translate_TimeBucket_Int_Int_Offset() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(int), typeof(int), typeof(int)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(3, typeof(int)); + } + + #endregion + + #region Should_Translate_TimeBucket_Long_Long + + [Fact] + public void Should_Translate_TimeBucket_Long_Long() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(2, typeof(long)); + } + + #endregion + + #region Should_Translate_TimeBucket_Long_Long_Offset + + [Fact] + public void Should_Translate_TimeBucket_Long_Long_Offset() + { + // Arrange + MethodInfo method = typeof(TimescaleDbFunctionsExtensions).GetMethod( + nameof(TimescaleDbFunctionsExtensions.TimeBucket), [typeof(DbFunctions), typeof(long), typeof(long), typeof(long)])!; + List arguments = [CreateMockArgument(), CreateMockArgument(), CreateMockArgument(), CreateMockArgument()]; + + // Act + _translator.Translate(null, method, arguments, _loggerMock.Object); + + // Assert + AssertTimeBucketTranslation(3, typeof(long)); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/coverlet.runsettings b/tests/Eftdb.Tests/coverlet.runsettings index fb55f96..caf0a4d 100644 --- a/tests/Eftdb.Tests/coverlet.runsettings +++ b/tests/Eftdb.Tests/coverlet.runsettings @@ -16,7 +16,9 @@ **/Migrations/*.cs, **/WhereClauseEpressionVisitor.cs, **/TimescaleDBDesignTimeServices.cs, - **/TimescaleDbServiceCollectionExtensions.cs + **/TimescaleDbServiceCollectionExtensions.cs, + **/TimescaleDbFunctionsExtensions.cs, + **/TimescaleDbFunctionsExtensions.TimeBucket.cs false true