diff --git a/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj b/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj index 70c543c..789cb30 100644 --- a/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj +++ b/benchmarks/Eftdb.Benchmarks/Eftdb.Benchmarks.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs b/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs index ec5a5f5..a7ac43b 100644 --- a/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs +++ b/benchmarks/Eftdb.Benchmarks/WriteRecordsBenchmarkBase.cs @@ -11,8 +11,7 @@ public abstract class WriteRecordsBenchmarkBase where T : class public int MaxBatchSize; public int NumberOfWorkers; - private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg17") + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("benchmark_tests_db") .WithUsername("test_user") .WithPassword("test_password") diff --git a/docs/data-annotations/retention-policies.md b/docs/data-annotations/retention-policies.md new file mode 100644 index 0000000..6576b78 --- /dev/null +++ b/docs/data-annotations/retention-policies.md @@ -0,0 +1,94 @@ +# Retention Policies + +A retention policy automatically drops old chunks from a hypertable or continuous aggregate on a scheduled basis. This keeps storage consumption bounded without requiring manual intervention and is the standard approach for managing time-series data lifecycle in TimescaleDB. + +Each hypertable or continuous aggregate supports at most one retention policy. + +[See also: add_retention_policy](https://docs.tigerdata.com/api/latest/data_retention/add_retention_policy/) + +## Drop Modes + +Two mutually exclusive drop modes are available: + +- **`DropAfter`**: Drops chunks whose data falls outside a time window relative to the current time. This is the standard mode. +- **`DropCreatedBefore`**: Drops chunks created before a specified interval ago, regardless of the data they contain. + +Exactly one of `DropAfter` or `DropCreatedBefore` must be specified. Providing both or neither raises an exception. + +## Basic Example + +Here is a complete example of configuring a retention policy on an `ApplicationLog` hypertable using `DropAfter`. + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), ChunkTimeInterval = "1 day")] +[PrimaryKey(nameof(Id), nameof(Time))] +[RetentionPolicy("30 days")] +public class ApplicationLog +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string ServiceName { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} +``` + +## Using `DropCreatedBefore` + +Pass `null` as the first argument and provide `dropCreatedBefore` as a named argument: + +```csharp +[Hypertable(nameof(Time), ChunkTimeInterval = "1 day")] +[PrimaryKey(nameof(Id), nameof(Time))] +[RetentionPolicy(dropCreatedBefore: "30 days")] +public class ApiRequestLog +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string Path { get; set; } = string.Empty; + public int StatusCode { get; set; } +} +``` + +> :warning: **Note:** Due to a known bug in TimescaleDB ([#9446](https://github.com/timescale/timescaledb/issues/9446)), `alter_job` fails when used with `DropCreatedBefore` policies. The library works around this by skipping the `alter_job` call for `DropCreatedBefore` policies. As a result, job scheduling parameters (`ScheduleInterval`, `MaxRuntime`, `MaxRetries`, `RetryPeriod`) are accepted by the API but have no effect at the database level when `DropCreatedBefore` is used. + +## Complete Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; + +[Hypertable(nameof(Time), ChunkTimeInterval = "1 day")] +[PrimaryKey(nameof(Id), nameof(Time))] +[RetentionPolicy("30 days", + InitialStart = "2025-10-01T03:00:00Z", + ScheduleInterval = "1 day", + MaxRuntime = "30 minutes", + MaxRetries = 3, + RetryPeriod = "5 minutes")] +public class ApplicationLog +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string ServiceName { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} +``` + +## Supported Parameters + +| Parameter | Description | Type | Database Type | Default Value | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------- | ------------------------------------------- | +| `DropAfter` | The interval after which chunks are dropped. Mutually exclusive with `DropCreatedBefore`. Can be passed as the first positional argument. | `string?` | `INTERVAL` | — | +| `DropCreatedBefore` | The interval before which chunks created are dropped. Based on chunk creation time. Only supports `INTERVAL`. Not available for integer-based time columns. Mutually exclusive with `DropAfter`. | `string?` | `INTERVAL` | — | +| `InitialStart` | The first time the policy job is scheduled to run, specified as a UTC date-time string in ISO 8601 format. If `null`, the first run is based on the `ScheduleInterval`. | `string?` | `TIMESTAMPTZ` | `null` | +| `ScheduleInterval` | The interval at which the retention policy job runs. | `string?` | `INTERVAL` | `'1 day'` | +| `MaxRuntime` | The maximum amount of time the job is allowed to run before being stopped. If `null`, there is no time limit. | `string?` | `INTERVAL` | `'00:00:00'` | +| `MaxRetries` | The number of times the job is retried if it fails. | `int` | `INTEGER` | `-1` | +| `RetryPeriod` | The amount of time the scheduler waits between retries of a failed job. | `string?` | `INTERVAL` | Equal to the `scheduleInterval` by default. | diff --git a/docs/fluent-api/retention-policies.md b/docs/fluent-api/retention-policies.md new file mode 100644 index 0000000..8aa517e --- /dev/null +++ b/docs/fluent-api/retention-policies.md @@ -0,0 +1,110 @@ +# Retention Policies + +A retention policy automatically drops old chunks from a hypertable or continuous aggregate on a scheduled basis. This keeps storage consumption bounded without requiring manual intervention and is the standard approach for managing time-series data lifecycle in TimescaleDB. + +Each hypertable or continuous aggregate supports at most one retention policy. + +[See also: add_retention_policy](https://docs.tigerdata.com/api/latest/data_retention/add_retention_policy/) + +## Drop Modes + +Two mutually exclusive drop modes are available: + +- **`dropAfter`**: Drops chunks whose data falls outside a time window relative to the current time. This is the standard mode. +- **`dropCreatedBefore`**: Drops chunks created before a specified interval ago, regardless of the data they contain. + +Exactly one of `dropAfter` or `dropCreatedBefore` must be specified. Providing both or neither raises an exception. + +## Basic Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ApplicationLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day"); + + // Drop chunks older than 30 days, running the job daily + builder.WithRetentionPolicy( + dropAfter: "30 days", + scheduleInterval: "1 day"); + } +} +``` + +## Using `dropCreatedBefore` + +```csharp +public class ApiRequestLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day"); + + // Drop chunks created more than 30 days ago + builder.WithRetentionPolicy( + dropCreatedBefore: "30 days", + scheduleInterval: "1 day"); + } +} +``` + +> :warning: **Note:** Due to a known bug in TimescaleDB ([#9446](https://github.com/timescale/timescaledb/issues/9446)), `alter_job` fails when used with `drop_created_before` policies. The library works around this by skipping the `alter_job` call for `drop_created_before` policies. As a result, job scheduling parameters (`scheduleInterval`, `maxRuntime`, `maxRetries`, `retryPeriod`) are accepted by the API but have no effect at the database level when `dropCreatedBefore` is used. + +## Complete Example + +```csharp +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ApplicationLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => new { x.Id, x.Time }); + + builder.IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day"); + + builder.WithRetentionPolicy( + dropAfter: "30 days", + initialStart: new DateTime(2025, 10, 1, 3, 0, 0, DateTimeKind.Utc), + scheduleInterval: "1 day", + maxRuntime: "30 minutes", + maxRetries: 3, + retryPeriod: "5 minutes"); + } +} + +public class ApplicationLog +{ + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string ServiceName { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} +``` + +## Supported Parameters + +| Parameter | Description | Type | Database Type | Default Value | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ------------- | ------------------------------------------- | +| `dropAfter` | The interval after which chunks are dropped. Mutually exclusive with `dropCreatedBefore`. | `string?` | `INTERVAL` | — | +| `dropCreatedBefore` | The interval before which chunks created are dropped. Based on chunk creation time. Only supports `INTERVAL`. Not available for integer-based time columns. Mutually exclusive with `dropAfter`. | `string?` | `INTERVAL` | — | +| `initialStart` | The first time the policy job is scheduled to run, as a UTC `DateTime`. If `null`, the first run is based on the `scheduleInterval`. | `DateTime?` | `TIMESTAMPTZ` | `null` | +| `scheduleInterval` | The interval at which the retention policy job runs. | `string?` | `INTERVAL` | `'1 day'` | +| `maxRuntime` | The maximum amount of time the job is allowed to run before being stopped. If `null`, there is no time limit. | `string?` | `INTERVAL` | `'00:00:00'` | +| `maxRetries` | The number of times the job is retried if it fails. | `int?` | `INTEGER` | `-1` | +| `retryPeriod` | The amount of time the scheduler waits between retries of a failed job. | `string?` | `INTERVAL` | Equal to the `scheduleInterval` by default. | diff --git a/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj b/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj index 01a37be..a3808d7 100644 --- a/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj +++ b/samples/Eftdb.Samples.CodeFirst/Eftdb.Samples.CodeFirst.csproj @@ -1,28 +1,28 @@  - - Exe - net10.0 - enable - enable - CmdScale.EntityFrameworkCore.TimescaleDB.Samples.CodeFirst - CmdScale.EntityFrameworkCore.TimescaleDB.Samples.CodeFirst - + + Exe + net10.0 + enable + enable + CmdScale.EntityFrameworkCore.TimescaleDB.Samples.CodeFirst + CmdScale.EntityFrameworkCore.TimescaleDB.Samples.CodeFirst + - - - - + + + + - - - - + + + + - - - PreserveNewest - - + + + PreserveNewest + + diff --git a/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj b/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj index 18474c2..6b1b2bf 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj +++ b/samples/Eftdb.Samples.DatabaseFirst/Eftdb.Samples.DatabaseFirst.csproj @@ -1,15 +1,15 @@ - - - + + + - - net10.0 - enable - enable - CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst - CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst - + + net10.0 + enable + enable + CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst + CmdScale.EntityFrameworkCore.TimescaleDB.Samples.DatabaseFirst + diff --git a/samples/Eftdb.Samples.DatabaseFirst/README.md b/samples/Eftdb.Samples.DatabaseFirst/README.md index 81f5f8c..61dfe0e 100644 --- a/samples/Eftdb.Samples.DatabaseFirst/README.md +++ b/samples/Eftdb.Samples.DatabaseFirst/README.md @@ -2,6 +2,11 @@ This project demonstrates how to use the **Database-First** approach with [TimescaleDB](https://www.timescale.com/) using the `CmdScale.EntityFrameworkCore.TimescaleDB` package. + +> [!WARNING] +> Currently the `dotnet ef dbcontext scaffold` command can't be tested because of an issue in `efcore` (see https://github.com/dotnet/efcore/issues/37201). +>
TODO: Test as soon as there is a fix available + --- ## Required NuGet Packages diff --git a/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs new file mode 100644 index 0000000..493c170 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestAggregateConfiguration.cs @@ -0,0 +1,29 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + public class ApiRequestAggregateConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasNoKey(); + builder.IsContinuousAggregate("api_request_hourly_stats", "1 hour", x => x.Time, true) + .AddAggregateFunction(x => x.AverageDurationMs, x => x.DurationMs, EAggregateFunction.Avg) + .AddAggregateFunction(x => x.MaxDurationMs, x => x.DurationMs, EAggregateFunction.Max) + .AddAggregateFunction(x => x.MinDurationMs, x => x.DurationMs, EAggregateFunction.Min) + .AddGroupByColumn(x => x.ServiceName) + .WithRefreshPolicy(startOffset: "2 days", endOffset: "1 hour", scheduleInterval: "1 hour"); + builder.WithRetentionPolicy( + dropAfter: "90 days", + scheduleInterval: "1 day", + maxRetries: 3, + retryPeriod: "15 minutes"); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Configurations/ApiRequestLogConfiguration.cs b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestLogConfiguration.cs new file mode 100644 index 0000000..34e7a55 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Configurations/ApiRequestLogConfiguration.cs @@ -0,0 +1,24 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Configurations +{ + public class ApiRequestLogConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ApiRequestLogs"); + builder.HasNoKey() + .IsHypertable(x => x.Time) + .WithChunkTimeInterval("1 day"); + builder.WithRetentionPolicy( + dropCreatedBefore: "30 days", + scheduleInterval: "1 day", + maxRetries: 5, + retryPeriod: "10 minutes"); + } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/ApiRequestAggregate.cs b/samples/Eftdb.Samples.Shared/Models/ApiRequestAggregate.cs new file mode 100644 index 0000000..bd66405 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/ApiRequestAggregate.cs @@ -0,0 +1,11 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + public class ApiRequestAggregate + { + public DateTime TimeBucket { get; set; } + public string ServiceName { get; set; } = string.Empty; + public double AverageDurationMs { get; set; } + public double MaxDurationMs { get; set; } + public double MinDurationMs { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/ApiRequestLog.cs b/samples/Eftdb.Samples.Shared/Models/ApiRequestLog.cs new file mode 100644 index 0000000..064cbd6 --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/ApiRequestLog.cs @@ -0,0 +1,12 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + public class ApiRequestLog + { + public DateTime Time { get; set; } + public string Method { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public int StatusCode { get; set; } + public double DurationMs { get; set; } + public string ServiceName { get; set; } = string.Empty; + } +} diff --git a/samples/Eftdb.Samples.Shared/Models/ApplicationLog.cs b/samples/Eftdb.Samples.Shared/Models/ApplicationLog.cs new file mode 100644 index 0000000..e1172ec --- /dev/null +++ b/samples/Eftdb.Samples.Shared/Models/ApplicationLog.cs @@ -0,0 +1,23 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models +{ + [Hypertable(nameof(Time), ChunkTimeInterval = "1 day")] + [PrimaryKey(nameof(Id), nameof(Time))] + [RetentionPolicy("30 days", + InitialStart = "2025-10-01T03:00:00Z", + ScheduleInterval = "1 day", + MaxRetries = 3, + RetryPeriod = "5 minutes")] + public class ApplicationLog + { + public Guid Id { get; set; } + public DateTime Time { get; set; } + public string ServiceName { get; set; } = string.Empty; + public string Level { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string? ExceptionDetails { get; set; } + } +} diff --git a/samples/Eftdb.Samples.Shared/TimescaleContext.cs b/samples/Eftdb.Samples.Shared/TimescaleContext.cs index 04aec9c..91736f3 100644 --- a/samples/Eftdb.Samples.Shared/TimescaleContext.cs +++ b/samples/Eftdb.Samples.Shared/TimescaleContext.cs @@ -1,4 +1,4 @@ -using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; +using CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared.Models; using Microsoft.EntityFrameworkCore; namespace CmdScale.EntityFrameworkCore.TimescaleDB.Samples.Shared @@ -13,6 +13,9 @@ public class TimescaleContext(DbContextOptions options) : DbCo public DbSet TradesWithId { get; set; } public DbSet TradeAggregates { get; set; } public DbSet WeatherAggregates { get; set; } + public DbSet ApplicationLogs { get; set; } + public DbSet ApiRequestLogs { get; set; } + public DbSet ApiRequestAggregates { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -21,4 +24,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema("custom_schema"); } } -} \ No newline at end of file +} diff --git a/src/Eftdb.Design/Eftdb.Design.csproj b/src/Eftdb.Design/Eftdb.Design.csproj index e9819d3..7f88566 100644 --- a/src/Eftdb.Design/Eftdb.Design.csproj +++ b/src/Eftdb.Design/Eftdb.Design.csproj @@ -1,48 +1,48 @@  - - net10.0 - enable - enable - CmdScale.EntityFrameworkCore.TimescaleDB.Design - CmdScale.EntityFrameworkCore.TimescaleDB.Design - CmdScale.EntityFrameworkCore.TimescaleDB.Design - 1.0.0 - CmdScale - Provides crucial design-time extensions. This package enhances the EF Core CLI tools (dotnet ef) to understand TimescaleDB concepts, enabling correct schema generation for migrations and scaffolding. - true - CmdScale.EntityFrameworkCore.TimescaleDB.Design - true - CmdScale - https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB - cmd-nuget-logo.jpg - https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB - True - snupkg - README.md - LICENSE - True - timescaledb;timescale;efcore;ef-core;entityframeworkcore;postgresql;postgres;time-series;timeseries;data;database;efcore-provider;provider;design;migrations;scaffolding;codegen;cli;tools - - - - - - - True - \ - - - True - \ - - - True - \ - - - - - - - + + net10.0 + enable + enable + CmdScale.EntityFrameworkCore.TimescaleDB.Design + CmdScale.EntityFrameworkCore.TimescaleDB.Design + CmdScale.EntityFrameworkCore.TimescaleDB.Design + 1.0.0 + CmdScale + Provides crucial design-time extensions. This package enhances the EF Core CLI tools (dotnet ef) to understand TimescaleDB concepts, enabling correct schema generation for migrations and scaffolding. + true + CmdScale.EntityFrameworkCore.TimescaleDB.Design + true + CmdScale + https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB + cmd-nuget-logo.jpg + https://github.com/cmdscale/CmdScale.EntityFrameworkCore.TimescaleDB + True + snupkg + README.md + LICENSE + True + timescaledb;timescale;efcore;ef-core;entityframeworkcore;postgresql;postgres;time-series;timeseries;data;database;efcore-provider;provider;design;migrations;scaffolding;codegen;cli;tools + + + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + \ No newline at end of file diff --git a/src/Eftdb.Design/Scaffolding/RetentionPolicyAnnotationApplier.cs b/src/Eftdb.Design/Scaffolding/RetentionPolicyAnnotationApplier.cs new file mode 100644 index 0000000..6c0ea7f --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/RetentionPolicyAnnotationApplier.cs @@ -0,0 +1,58 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.RetentionPolicyScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Applies retention policy annotations to scaffolded database tables. + /// + public sealed class RetentionPolicyAnnotationApplier : IAnnotationApplier + { + public void ApplyAnnotations(DatabaseTable table, object featureInfo) + { + if (featureInfo is not RetentionPolicyInfo policyInfo) + { + throw new ArgumentException($"Expected {nameof(RetentionPolicyInfo)}, got {featureInfo.GetType().Name}", nameof(featureInfo)); + } + + table[RetentionPolicyAnnotations.HasRetentionPolicy] = true; + + if (!string.IsNullOrWhiteSpace(policyInfo.DropAfter)) + { + table[RetentionPolicyAnnotations.DropAfter] = policyInfo.DropAfter; + } + + if (!string.IsNullOrWhiteSpace(policyInfo.DropCreatedBefore)) + { + table[RetentionPolicyAnnotations.DropCreatedBefore] = policyInfo.DropCreatedBefore; + } + + if (policyInfo.InitialStart.HasValue) + { + table[RetentionPolicyAnnotations.InitialStart] = policyInfo.InitialStart.Value; + } + + // Set annotations only if they differ from TimescaleDB defaults + if (policyInfo.ScheduleInterval != DefaultValues.RetentionPolicyScheduleInterval) + { + table[RetentionPolicyAnnotations.ScheduleInterval] = policyInfo.ScheduleInterval; + } + + if (policyInfo.MaxRuntime != DefaultValues.RetentionPolicyMaxRuntime) + { + table[RetentionPolicyAnnotations.MaxRuntime] = policyInfo.MaxRuntime; + } + + if (policyInfo.MaxRetries != DefaultValues.RetentionPolicyMaxRetries) + { + table[RetentionPolicyAnnotations.MaxRetries] = policyInfo.MaxRetries; + } + + if (policyInfo.RetryPeriod != DefaultValues.RetentionPolicyScheduleInterval) + { + table[RetentionPolicyAnnotations.RetryPeriod] = policyInfo.RetryPeriod; + } + } + } +} diff --git a/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs new file mode 100644 index 0000000..2e46976 --- /dev/null +++ b/src/Eftdb.Design/Scaffolding/RetentionPolicyScaffoldingExtractor.cs @@ -0,0 +1,114 @@ +using System.Data; +using System.Data.Common; +using System.Text.Json; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding +{ + /// + /// Extracts retention policy metadata from a TimescaleDB database for scaffolding. + /// + public sealed class RetentionPolicyScaffoldingExtractor : ITimescaleFeatureExtractor + { + public sealed record RetentionPolicyInfo( + string? DropAfter, + string? DropCreatedBefore, + DateTime? InitialStart, + string? ScheduleInterval, + string? MaxRuntime, + int? MaxRetries, + string? RetryPeriod + ); + + public Dictionary<(string Schema, string TableName), object> Extract(DbConnection connection) + { + bool wasOpen = connection.State == ConnectionState.Open; + if (!wasOpen) + { + connection.Open(); + } + + try + { + Dictionary<(string, string), RetentionPolicyInfo> retentionPolicies = []; + + using (DbCommand command = connection.CreateCommand()) + { + command.CommandText = @" + SELECT + j.hypertable_schema, + j.hypertable_name, + j.config, + j.initial_start, + j.schedule_interval::text, + j.max_runtime::text, + j.max_retries, + j.retry_period::text + FROM timescaledb_information.jobs AS j + WHERE j.proc_name = 'policy_retention';"; + + using DbDataReader reader = command.ExecuteReader(); + while (reader.Read()) + { + string schema = reader.GetString(0); + string name = reader.GetString(1); + string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2); + DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); + string? scheduleInterval = reader.IsDBNull(4) ? null : reader.GetString(4); + string? maxRuntime = reader.IsDBNull(5) ? null : reader.GetString(5); + int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); + string? retryPeriod = reader.IsDBNull(7) ? null : reader.GetString(7); + + // Parse the JSONB config to extract drop_after or drop_created_before + string? dropAfter = null; + string? dropCreatedBefore = null; + + if (!string.IsNullOrWhiteSpace(configJson)) + { + using JsonDocument doc = JsonDocument.Parse(configJson); + JsonElement root = doc.RootElement; + + if (root.TryGetProperty("drop_after", out JsonElement dropAfterElement)) + { + dropAfter = IntervalParsingHelper.ParseIntervalOrInteger(dropAfterElement); + } + + if (root.TryGetProperty("drop_created_before", out JsonElement dropCreatedBeforeElement)) + { + dropCreatedBefore = IntervalParsingHelper.ParseIntervalOrInteger(dropCreatedBeforeElement); + } + } + + // A retention policy must have either drop_after or drop_created_before + if (string.IsNullOrWhiteSpace(dropAfter) && string.IsNullOrWhiteSpace(dropCreatedBefore)) + { + continue; + } + + retentionPolicies[(schema, name)] = new RetentionPolicyInfo( + DropAfter: dropAfter, + DropCreatedBefore: dropCreatedBefore, + InitialStart: initialStart, + ScheduleInterval: scheduleInterval, + MaxRuntime: maxRuntime, + MaxRetries: maxRetries, + RetryPeriod: retryPeriod + ); + } + } + + // Convert to object dictionary to match interface + return retentionPolicies.ToDictionary( + kvp => kvp.Key, + kvp => (object)kvp.Value + ); + } + finally + { + if (!wasOpen) + { + connection.Close(); + } + } + } + } +} diff --git a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs index a1a4afd..306cb1f 100644 --- a/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs +++ b/src/Eftdb.Design/TimescaleCSharpMigrationOperationGenerator.cs @@ -15,6 +15,7 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui HypertableOperationGenerator? hypertableOperationGenerator = null; ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null; + RetentionPolicyOperationGenerator? retentionPolicyOperationGenerator = null; ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null; ContinuousAggregatePolicyOperationGenerator? continuousAggregatePolicyOperationGenerator = null; @@ -45,6 +46,19 @@ protected override void Generate(MigrationOperation operation, IndentedStringBui statements = reorderPolicyOperationGenerator.Generate(dropReorder); break; + case AddRetentionPolicyOperation addRetention: + retentionPolicyOperationGenerator ??= new(isDesignTime: true); + statements = retentionPolicyOperationGenerator.Generate(addRetention); + break; + case AlterRetentionPolicyOperation alterRetention: + retentionPolicyOperationGenerator ??= new(isDesignTime: true); + statements = retentionPolicyOperationGenerator.Generate(alterRetention); + break; + case DropRetentionPolicyOperation dropRetention: + retentionPolicyOperationGenerator ??= new(isDesignTime: true); + statements = retentionPolicyOperationGenerator.Generate(dropRetention); + break; + case CreateContinuousAggregateOperation createContinuousAggregate: continuousAggregateOperationGenerator ??= new(isDesignTime: true); statements = continuousAggregateOperationGenerator.Generate(createContinuousAggregate); diff --git a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs index 5da4b7a..42a2da0 100644 --- a/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs +++ b/src/Eftdb.Design/TimescaleDatabaseModelFactory.cs @@ -21,7 +21,8 @@ public class TimescaleDatabaseModelFactory(IDiagnosticsLogger + /// Contains constants for annotations used by the TimescaleDB retention policy feature. + /// + public static class RetentionPolicyAnnotations + { + public const string HasRetentionPolicy = "TimescaleDB:HasRetentionPolicy"; + public const string DropAfter = "TimescaleDB:RetentionPolicy:DropAfter"; + public const string DropCreatedBefore = "TimescaleDB:RetentionPolicy:DropCreatedBefore"; + public const string InitialStart = "TimescaleDB:RetentionPolicy:InitialStart"; + + public const string ScheduleInterval = "TimescaleDB:RetentionPolicy:ScheduleInterval"; + public const string MaxRuntime = "TimescaleDB:RetentionPolicy:MaxRuntime"; + public const string MaxRetries = "TimescaleDB:RetentionPolicy:MaxRetries"; + public const string RetryPeriod = "TimescaleDB:RetentionPolicy:RetryPeriod"; + } +} diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs new file mode 100644 index 0000000..e1c5ddf --- /dev/null +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyAttribute.cs @@ -0,0 +1,108 @@ +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class RetentionPolicyAttribute : Attribute + { + /// + /// Gets or sets the interval after which chunks are dropped. + /// Mutually exclusive with ; exactly one must be specified. + /// + /// + /// "7 days" + /// + public string? DropAfter { get; set; } + + /// + /// Gets or sets the interval before which chunks created are dropped. + /// Mutually exclusive with ; exactly one must be specified. + /// Only supported for hypertables, not continuous aggregates. + /// + /// + /// "30 days" + /// + /// + /// When you use DropCreatedBefore, instead of DropAfter, arguments related to the alter_job function like MaxRuntime, MaxRetries, + /// or RetryPeriod are not supported. + /// The reason for this is a bug in TimescaleDB itself. See this issue for further information. + /// + public string? DropCreatedBefore { get; set; } + + /// + /// Gets or sets the first time the policy job is scheduled to run. + /// Can be specified as a UTC date-time string in ISO 8601 format. + /// If not set, the first run is scheduled based on the schedule_interval. + /// + /// + /// "2025-10-01T03:00:00Z" + /// + public string? InitialStart { get; set; } + + /// + /// Gets or sets the interval at which the retention policy job runs. + /// If not set, it defaults to the TimescaleDB server default. + /// + /// + /// "1 day" + /// + public string? ScheduleInterval { get; set; } + + /// + /// Gets or sets the maximum amount of time the job is allowed to run before being stopped. + /// If not set, there is no time limit. + /// + /// + /// "1 hour" + /// + public string? MaxRuntime { get; set; } + + /// + /// Gets or sets the number of times the job is retried if it fails. + /// If not set, it defaults to -1 (retry indefinitely). + /// + public int MaxRetries { get; set; } = -1; + + /// + /// Gets or sets the amount of time the scheduler waits between retries of a failed job. + /// + /// + /// "30 minutes" + /// + public string? RetryPeriod { get; set; } + + /// + /// Configures a retention policy using drop_after. + /// + /// The interval after which chunks are dropped (e.g., "7 days"). + public RetentionPolicyAttribute(string dropAfter) + { + if (string.IsNullOrWhiteSpace(dropAfter)) + { + throw new ArgumentException("DropAfter must be provided.", nameof(dropAfter)); + } + + DropAfter = dropAfter; + } + + /// + /// Configures a retention policy. Exactly one of or must be non-null. + /// + public RetentionPolicyAttribute(string? dropAfter = null, string? dropCreatedBefore = null) + { + bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); + bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); + + if (hasDropAfter && hasDropCreatedBefore) + { + throw new InvalidOperationException("RetentionPolicy: 'DropAfter' and 'DropCreatedBefore' are mutually exclusive. Specify exactly one."); + } + + if (!hasDropAfter && !hasDropCreatedBefore) + { + throw new InvalidOperationException("RetentionPolicy: Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified."); + } + + DropAfter = dropAfter; + DropCreatedBefore = dropCreatedBefore; + } + } +} diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs new file mode 100644 index 0000000..bd7a284 --- /dev/null +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyConvention.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using System.Reflection; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy +{ + /// + /// A convention that configures the retention policy for a hypertable or continuous aggregate + /// based on the presence of the [RetentionPolicy] attribute. + /// + public class RetentionPolicyConvention : IEntityTypeAddedConvention + { + /// + /// Called when an entity type is added to the model. + /// + /// The builder for the entity type. + /// Additional information available during convention execution. + public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilder, IConventionContext context) + { + IConventionEntityType entityType = entityTypeBuilder.Metadata; + RetentionPolicyAttribute? attribute = entityType.ClrType?.GetCustomAttribute(); + + if (attribute != null) + { + bool hasDropAfter = !string.IsNullOrWhiteSpace(attribute.DropAfter); + bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(attribute.DropCreatedBefore); + + if (hasDropAfter && hasDropCreatedBefore) + { + throw new InvalidOperationException( + $"[RetentionPolicy] on '{entityType.ClrType?.Name}': 'DropAfter' and 'DropCreatedBefore' are mutually exclusive. Specify exactly one."); + } + + if (!hasDropAfter && !hasDropCreatedBefore) + { + throw new InvalidOperationException( + $"[RetentionPolicy] on '{entityType.ClrType?.Name}': Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified."); + } + + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + + if (hasDropAfter) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropAfter, attribute.DropAfter!); + + if (hasDropCreatedBefore) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, attribute.DropCreatedBefore!); + + if (!string.IsNullOrWhiteSpace(attribute.InitialStart)) + { + if (DateTime.TryParse(attribute.InitialStart, out DateTime parsedDateTimeOffset)) + { + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, parsedDateTimeOffset); + } + else + { + throw new InvalidOperationException($"InitialStart '{attribute.InitialStart}' is not a valid DateTime format. Please use a valid DateTime string."); + } + } + + if (!string.IsNullOrWhiteSpace(attribute.ScheduleInterval)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, attribute.ScheduleInterval); + + if (!string.IsNullOrWhiteSpace(attribute.MaxRuntime)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRuntime, attribute.MaxRuntime); + + if (attribute.MaxRetries > -1) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRetries, attribute.MaxRetries); + + if (!string.IsNullOrWhiteSpace(attribute.RetryPeriod)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.RetryPeriod, attribute.RetryPeriod); + } + } + } +} diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs new file mode 100644 index 0000000..6a5b38b --- /dev/null +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy +{ + /// + /// Provides extension methods for configuring TimescaleDB retention policies using the EF Core Fluent API. + /// + public static class RetentionPolicyTypeBuilder + { + /// + /// Configures a TimescaleDB retention policy for the entity using a fluent API. + /// Exactly one of or must be specified. + /// + /// + /// A retention policy automatically drops chunks whose data is older than a specified interval. + ///

+ /// NOTE: When you use DropCreatedBefore, instead of DropAfter, arguments related to the alter_job function like MaxRuntime, MaxRetries, + /// or RetryPeriod are not supported. + /// The reason for this is a bug in TimescaleDB itself. See this issue for further information. + ///
+ /// + /// + /// modelBuilder.Entity<DeviceReading>() + /// .WithRetentionPolicy( + /// dropAfter: "7 days", + /// scheduleInterval: "1 day", + /// maxRetries: 5); + /// + /// + /// The type of the entity being configured. + /// The builder for the entity type being configured. + /// The interval after which chunks are dropped. Mutually exclusive with . + /// The interval before which chunks created are dropped. Mutually exclusive with . Not supported for continuous aggregates. + /// The first time the policy job is scheduled to run. If null, it's based on the schedule interval. + /// The interval at which the retention policy job runs. + /// The maximum amount of time the job is allowed to run. If null, there is no time limit. + /// The number of times the job is retried if it fails. Defaults to -1 (retry indefinitely) if not specified. + /// The amount of time the scheduler waits between retries. + /// The same builder instance so that multiple calls can be chained. + public static EntityTypeBuilder WithRetentionPolicy( + this EntityTypeBuilder entityTypeBuilder, + string? dropAfter = null, + string? dropCreatedBefore = null, + DateTime? initialStart = null, + string? scheduleInterval = null, + string? maxRuntime = null, + int? maxRetries = null, + string? retryPeriod = null) where TEntity : class + { + bool hasDropAfter = !string.IsNullOrWhiteSpace(dropAfter); + bool hasDropCreatedBefore = !string.IsNullOrWhiteSpace(dropCreatedBefore); + + if (hasDropAfter && hasDropCreatedBefore) + { + throw new InvalidOperationException("WithRetentionPolicy: 'dropAfter' and 'dropCreatedBefore' are mutually exclusive. Specify exactly one."); + } + + if (!hasDropAfter && !hasDropCreatedBefore) + { + throw new InvalidOperationException("WithRetentionPolicy: Exactly one of 'dropAfter' or 'dropCreatedBefore' must be specified."); + } + + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + + if (hasDropAfter) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropAfter, dropAfter!); + + if (hasDropCreatedBefore) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, dropCreatedBefore!); + + if (initialStart.HasValue) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, initialStart); + + if (!string.IsNullOrWhiteSpace(scheduleInterval)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, scheduleInterval); + + if (!string.IsNullOrWhiteSpace(maxRuntime)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRuntime, maxRuntime); + + if (maxRetries.HasValue) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.MaxRetries, maxRetries.Value); + + if (!string.IsNullOrWhiteSpace(retryPeriod)) + entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.RetryPeriod, retryPeriod); + + return entityTypeBuilder; + } + } +} diff --git a/src/Eftdb/DefaultValues.cs b/src/Eftdb/DefaultValues.cs index b0202d1..9a50386 100644 --- a/src/Eftdb/DefaultValues.cs +++ b/src/Eftdb/DefaultValues.cs @@ -11,5 +11,9 @@ public static class DefaultValues public const string ReorderPolicyScheduleInterval = "1 day"; public const int ReorderPolicyMaxRetries = -1; public const string ReorderPolicyMaxRuntime = "00:00:00"; + + public const string RetentionPolicyScheduleInterval = "1 day"; + public const int RetentionPolicyMaxRetries = -1; + public const string RetentionPolicyMaxRuntime = "00:00:00"; } } diff --git a/src/Eftdb/Eftdb.csproj b/src/Eftdb/Eftdb.csproj index 2aef2ee..720996e 100644 --- a/src/Eftdb/Eftdb.csproj +++ b/src/Eftdb/Eftdb.csproj @@ -41,6 +41,6 @@
- + \ No newline at end of file diff --git a/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs b/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs new file mode 100644 index 0000000..d74da86 --- /dev/null +++ b/src/Eftdb/Generators/RetentionPolicyOperationGenerator.cs @@ -0,0 +1,185 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using System.Globalization; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Generators +{ + public class RetentionPolicyOperationGenerator + { + private readonly string quoteString = "\""; + private readonly SqlBuilderHelper sqlHelper; + + public RetentionPolicyOperationGenerator(bool isDesignTime = false) + { + if (isDesignTime) + { + quoteString = "\"\""; + } + + sqlHelper = new SqlBuilderHelper(quoteString); + } + + public List Generate(AddRetentionPolicyOperation operation) + { + List statements = + [ + BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart) + ]; + + List alterJobClauses = BuildAlterJobClauses(operation); + if (alterJobClauses.Count != 0) + { + statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, alterJobClauses)); + } + + return statements; + } + + public List Generate(AlterRetentionPolicyOperation operation) + { + string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); + + List statements = []; + bool needsRecreation = + operation.DropAfter != operation.OldDropAfter || + operation.DropCreatedBefore != operation.OldDropCreatedBefore || + operation.InitialStart != operation.OldInitialStart; + + if (needsRecreation) + { + statements.Add($"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);"); + statements.Add(BuildAddRetentionPolicySql(operation.TableName, operation.Schema, operation.DropAfter, operation.DropCreatedBefore, operation.InitialStart)); + + // Create a temporary "add" operation representing the final desired state to ensure existing settings are reapplied. + AddRetentionPolicyOperation finalStateOperation = new() + { + TableName = operation.TableName, + Schema = operation.Schema, + DropAfter = operation.DropAfter, + DropCreatedBefore = operation.DropCreatedBefore, + InitialStart = operation.InitialStart, + ScheduleInterval = operation.ScheduleInterval, + MaxRuntime = operation.MaxRuntime, + MaxRetries = operation.MaxRetries, + RetryPeriod = operation.RetryPeriod + }; + + List finalStateClauses = BuildAlterJobClauses(finalStateOperation); + if (finalStateClauses.Count != 0) + { + statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, finalStateClauses)); + } + } + else + { + List changedClauses = BuildAlterJobClauses(operation); + if (changedClauses.Count != 0) + { + statements.Add(BuildAlterJobSql(operation.TableName, operation.Schema, changedClauses)); + } + } + + return statements; + } + + public List Generate(DropRetentionPolicyOperation operation) + { + string qualifiedTableName = sqlHelper.Regclass(operation.TableName, operation.Schema); + + List statements = + [ + $"SELECT remove_retention_policy({qualifiedTableName}, if_exists => true);" + ]; + return statements; + } + + private static List BuildAlterJobClauses(AddRetentionPolicyOperation operation) + { + List clauses = []; + + // alter_job fails for drop_created_before retention policies. + // TimescaleDB's policy_retention_check expects drop_after in the job config JSONB + // but finds drop_created_before instead. Workaround: avoid alter_job for + // drop_created_before policies, or recreate the policy entirely. + // TODO: Remove this when a fix has been applied to TimescaleDB. + if (!string.IsNullOrEmpty(operation.DropCreatedBefore)) + { + return clauses; + } + + if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval)) + clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); + + if (!string.IsNullOrWhiteSpace(operation.MaxRuntime)) + clauses.Add($"max_runtime => INTERVAL '{operation.MaxRuntime}'"); + + if (operation.MaxRetries != null) + clauses.Add($"max_retries => {operation.MaxRetries}"); + + if (!string.IsNullOrWhiteSpace(operation.RetryPeriod)) + clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); + + return clauses; + } + + private static List BuildAlterJobClauses(AlterRetentionPolicyOperation operation) + { + List clauses = []; + + // alter_job fails for drop_created_before retention policies. + // TimescaleDB's policy_retention_check expects drop_after in the job config JSONB + // but finds drop_created_before instead. Workaround: avoid alter_job for + // drop_created_before policies, or recreate the policy entirely. + // TODO: Remove this when a fix has been applied to TimescaleDB. + if (!string.IsNullOrEmpty(operation.DropCreatedBefore)) + { + return clauses; + } + + if (!string.IsNullOrWhiteSpace(operation.ScheduleInterval) && operation.ScheduleInterval != operation.OldScheduleInterval) + clauses.Add($"schedule_interval => INTERVAL '{operation.ScheduleInterval}'"); + + if (!string.IsNullOrWhiteSpace(operation.MaxRuntime) && operation.MaxRuntime != operation.OldMaxRuntime) + { + string maxRuntimeValue = string.IsNullOrWhiteSpace(operation.MaxRuntime) ? "NULL" : $"INTERVAL '{operation.MaxRuntime}'"; + clauses.Add($"max_runtime => {maxRuntimeValue}"); + } + + if (operation.MaxRetries != null && operation.MaxRetries != operation.OldMaxRetries) + clauses.Add($"max_retries => {operation.MaxRetries}"); + + if (!string.IsNullOrWhiteSpace(operation.RetryPeriod) && operation.RetryPeriod != operation.OldRetryPeriod) + clauses.Add($"retry_period => INTERVAL '{operation.RetryPeriod}'"); + + return clauses; + } + + private static string BuildAlterJobSql(string tableName, string schema, IEnumerable clauses) + { + // Note: hypertable_name is a varchar column, so it compares against a string literal, not a regclass. + return $@" + SELECT alter_job(job_id, {string.Join(", ", clauses)}) + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = '{schema}' AND hypertable_name = '{tableName}';".Trim(); + } + + private string BuildAddRetentionPolicySql(string tableName, string schema, string? dropAfter, string? dropCreatedBefore, DateTime? initialStart) + { + string qualifiedTableName = sqlHelper.Regclass(tableName, schema); + + List args = []; + + if (!string.IsNullOrWhiteSpace(dropAfter)) + args.Add($"drop_after => INTERVAL '{dropAfter}'"); + else if (!string.IsNullOrWhiteSpace(dropCreatedBefore)) + args.Add($"drop_created_before => INTERVAL '{dropCreatedBefore}'"); + + if (initialStart.HasValue) + { + string timestamp = initialStart.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + args.Add($"initial_start => '{timestamp}'"); + } + + return $"SELECT add_retention_policy({qualifiedTableName}, {string.Join(", ", args)});"; + } + } +} diff --git a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs new file mode 100644 index 0000000..2da4b02 --- /dev/null +++ b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs @@ -0,0 +1,71 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies +{ + public class RetentionPolicyDiffer : IFeatureDiffer + { + public IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) + { + List operations = []; + + List sourcePolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(source)]; + List targetPolicies = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(target)]; + + // Identify new retention policies + IEnumerable newRetentionPolicies = targetPolicies.Where(t => !sourcePolicies.Any(s => s.TableName == t.TableName && s.Schema == t.Schema)); + operations.AddRange(newRetentionPolicies); + + // Identify updated retention policies + var updatedRetentionPolicies = targetPolicies + .Join( + sourcePolicies, + targetPolicy => (targetPolicy.Schema, targetPolicy.TableName), + sourcePolicy => (sourcePolicy.Schema, sourcePolicy.TableName), + (targetPolicy, sourcePolicy) => new { Target = targetPolicy, Source = sourcePolicy } + ) + .Where(x => + x.Target.DropAfter != x.Source.DropAfter || + x.Target.DropCreatedBefore != x.Source.DropCreatedBefore || + x.Target.InitialStart != x.Source.InitialStart || + x.Target.ScheduleInterval != x.Source.ScheduleInterval || + x.Target.MaxRuntime != x.Source.MaxRuntime || + x.Target.MaxRetries != x.Source.MaxRetries || + x.Target.RetryPeriod != x.Source.RetryPeriod + ); + + foreach (var policy in updatedRetentionPolicies) + { + operations.Add(new AlterRetentionPolicyOperation + { + TableName = policy.Target.TableName, + Schema = policy.Target.Schema, + DropAfter = policy.Target.DropAfter, + DropCreatedBefore = policy.Target.DropCreatedBefore, + InitialStart = policy.Target.InitialStart, + ScheduleInterval = policy.Target.ScheduleInterval, + MaxRuntime = policy.Target.MaxRuntime, + MaxRetries = policy.Target.MaxRetries, + RetryPeriod = policy.Target.RetryPeriod, + + OldDropAfter = policy.Source.DropAfter, + OldDropCreatedBefore = policy.Source.DropCreatedBefore, + OldInitialStart = policy.Source.InitialStart, + OldScheduleInterval = policy.Source.ScheduleInterval, + OldMaxRuntime = policy.Source.MaxRuntime, + OldMaxRetries = policy.Source.MaxRetries, + OldRetryPeriod = policy.Source.RetryPeriod + }); + } + + // Identify removed retention policies + IEnumerable removedRetentionPolicies = sourcePolicies + .Where(s => !targetPolicies.Any(t => t.TableName == s.TableName && t.Schema == s.Schema)) + .Select(p => new DropRetentionPolicyOperation { TableName = p.TableName, Schema = p.Schema }); + operations.AddRange(removedRetentionPolicies); + + return operations; + } + } +} diff --git a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyModelExtractor.cs b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyModelExtractor.cs new file mode 100644 index 0000000..99c9fb7 --- /dev/null +++ b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyModelExtractor.cs @@ -0,0 +1,57 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies +{ + public static class RetentionPolicyModelExtractor + { + public static IEnumerable GetRetentionPolicies(IRelationalModel? relationalModel) + { + if (relationalModel == null) + { + yield break; + } + + foreach (IEntityType entityType in relationalModel.Model.GetEntityTypes()) + { + bool hasRetentionPolicy = entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value as bool? ?? false; + if (!hasRetentionPolicy) + { + continue; + } + + string? dropAfter = entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value as string; + string? dropCreatedBefore = entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)?.Value as string; + + if (string.IsNullOrWhiteSpace(dropAfter) && string.IsNullOrWhiteSpace(dropCreatedBefore)) + { + continue; + } + + // Resolve the target name: table name for hypertables, view name for continuous aggregates + string? targetName = entityType.GetTableName() ?? entityType.GetViewName(); + if (string.IsNullOrWhiteSpace(targetName)) + { + continue; + } + + DateTime? initialStart = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value as DateTime?; + + yield return new AddRetentionPolicyOperation + { + TableName = targetName, + Schema = entityType.GetSchema() ?? entityType.GetViewSchema() ?? DefaultValues.DefaultSchema, + DropAfter = dropAfter, + DropCreatedBefore = dropCreatedBefore, + InitialStart = initialStart, + ScheduleInterval = entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value as string ?? DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime = entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value as string ?? DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries = entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value as int? ?? DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod = entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value as string ?? DefaultValues.RetentionPolicyScheduleInterval + }; + } + } + } +} diff --git a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs index a7a8ed2..fd1e553 100644 --- a/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs +++ b/src/Eftdb/Internals/TimescaleMigrationsModelDiffer.cs @@ -3,6 +3,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregates; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.Hypertables; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; @@ -26,6 +27,7 @@ public class TimescaleMigrationsModelDiffer( new ReorderPolicyDiffer(), new ContinuousAggregateDiffer(), new ContinuousAggregatePolicyDiffer(), + new RetentionPolicyDiffer(), ]; public override IReadOnlyList GetDifferences(IRelationalModel? source, IRelationalModel? target) @@ -46,30 +48,51 @@ public override IReadOnlyList GetDifferences(IRelationalMode /// /// Assigns a priority to operations to ensure correct execution order. /// Lower numbers execute first. + /// Add/Create operations use positive priorities (run after standard EF table creation). + /// Drop operations use negative priorities (run before standard EF table drops). /// private static int GetOperationPriority(MigrationOperation operation) { switch (operation) { + // --- Drop operations: negative priorities, reverse dependency order --- + // Retention policies depend on hypertables and continuous aggregates + case DropRetentionPolicyOperation: + return -60; + + // CA policies depend on continuous aggregates + case RemoveContinuousAggregatePolicyOperation: + return -50; + + // Continuous aggregates depend on parent hypertables + case DropContinuousAggregateOperation: + return -40; + + // Reorder policies depend on hypertables + case DropReorderPolicyOperation: + return -20; + + // --- Add/Alter operations: positive priorities, dependency order --- case CreateHypertableOperation: return 10; case AddReorderPolicyOperation: case AlterReorderPolicyOperation: - case DropReorderPolicyOperation: return 20; case CreateContinuousAggregateOperation: return 30; case AlterContinuousAggregateOperation: - case DropContinuousAggregateOperation: return 40; case AddContinuousAggregatePolicyOperation: - case RemoveContinuousAggregatePolicyOperation: return 50; - // Standard EF Core operations (CreateTable, etc.) + case AddRetentionPolicyOperation: + case AlterRetentionPolicyOperation: + return 60; + + // Standard EF Core operations (CreateTable, DropTable, etc.) default: return 0; } diff --git a/src/Eftdb/Operations/AddRetentionPolicyOperation.cs b/src/Eftdb/Operations/AddRetentionPolicyOperation.cs new file mode 100644 index 0000000..86948b7 --- /dev/null +++ b/src/Eftdb/Operations/AddRetentionPolicyOperation.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + public class AddRetentionPolicyOperation : MigrationOperation + { + public string TableName { get; set; } = string.Empty; + public string Schema { get; set; } = string.Empty; + public string? DropAfter { get; set; } + public string? DropCreatedBefore { get; set; } + public DateTime? InitialStart { get; set; } + public string? ScheduleInterval { get; set; } + public string? MaxRuntime { get; set; } + public int? MaxRetries { get; set; } + public string? RetryPeriod { get; set; } + } +} diff --git a/src/Eftdb/Operations/AlterRetentionPolicyOperation.cs b/src/Eftdb/Operations/AlterRetentionPolicyOperation.cs new file mode 100644 index 0000000..f0921f5 --- /dev/null +++ b/src/Eftdb/Operations/AlterRetentionPolicyOperation.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + public class AlterRetentionPolicyOperation : MigrationOperation + { + public string TableName { get; set; } = string.Empty; + public string Schema { get; set; } = string.Empty; + + public string? DropAfter { get; set; } + public string? DropCreatedBefore { get; set; } + public DateTime? InitialStart { get; set; } + public string? ScheduleInterval { get; set; } + public string? MaxRuntime { get; set; } + public int? MaxRetries { get; set; } + public string? RetryPeriod { get; set; } + + public string? OldDropAfter { get; set; } + public string? OldDropCreatedBefore { get; set; } + public DateTime? OldInitialStart { get; set; } + public string? OldScheduleInterval { get; set; } + public string? OldMaxRuntime { get; set; } + public int? OldMaxRetries { get; set; } + public string? OldRetryPeriod { get; set; } + } +} diff --git a/src/Eftdb/Operations/DropRetentionPolicyOperation.cs b/src/Eftdb/Operations/DropRetentionPolicyOperation.cs new file mode 100644 index 0000000..473fe8e --- /dev/null +++ b/src/Eftdb/Operations/DropRetentionPolicyOperation.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Operations +{ + public class DropRetentionPolicyOperation : MigrationOperation + { + public string TableName { get; set; } = string.Empty; + public string Schema { get; set; } = string.Empty; + } +} diff --git a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs index b56e140..c9a471c 100644 --- a/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs +++ b/src/Eftdb/TimescaleDbContextOptionsBuilderExtensions.cs @@ -2,6 +2,7 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregatePolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Internals; using CmdScale.EntityFrameworkCore.TimescaleDB.Query.Internal; using Microsoft.EntityFrameworkCore; @@ -85,6 +86,7 @@ public ConventionSet ModifyConventions(ConventionSet conventionSet) conventionSet.EntityTypeAddedConventions.Add(new ReorderPolicyConvention()); conventionSet.EntityTypeAddedConventions.Add(new ContinuousAggregateConvention()); conventionSet.EntityTypeAddedConventions.Add(new ContinuousAggregatePolicyConvention()); + conventionSet.EntityTypeAddedConventions.Add(new RetentionPolicyConvention()); return conventionSet; } } diff --git a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs index 001bfc3..1934f87 100644 --- a/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs +++ b/src/Eftdb/TimescaleDbMigrationsSqlGenerator.cs @@ -19,6 +19,7 @@ protected override void Generate( List statements; HypertableOperationGenerator? hypertableOperationGenerator = null; ReorderPolicyOperationGenerator? reorderPolicyOperationGenerator = null; + RetentionPolicyOperationGenerator? retentionPolicyOperationGenerator = null; ContinuousAggregateOperationGenerator? continuousAggregateOperationGenerator = null; ContinuousAggregatePolicyOperationGenerator? continuousAggregatePolicyOperationGenerator = null; bool suppressTransaction = false; @@ -50,6 +51,21 @@ protected override void Generate( statements = reorderPolicyOperationGenerator.Generate(dropReorderPolicyOperation); break; + case AddRetentionPolicyOperation addRetentionPolicyOperation: + retentionPolicyOperationGenerator ??= new(isDesignTime: false); + statements = retentionPolicyOperationGenerator.Generate(addRetentionPolicyOperation); + break; + + case AlterRetentionPolicyOperation alterRetentionPolicyOperation: + retentionPolicyOperationGenerator ??= new(isDesignTime: false); + statements = retentionPolicyOperationGenerator.Generate(alterRetentionPolicyOperation); + break; + + case DropRetentionPolicyOperation dropRetentionPolicyOperation: + retentionPolicyOperationGenerator ??= new(isDesignTime: false); + statements = retentionPolicyOperationGenerator.Generate(dropRetentionPolicyOperation); + break; + case CreateContinuousAggregateOperation createContinuousAggregateOperation: continuousAggregateOperationGenerator ??= new(isDesignTime: false); statements = continuousAggregateOperationGenerator.Generate(createContinuousAggregateOperation); diff --git a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj index 1b8c2e9..8426c4f 100644 --- a/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj +++ b/tests/Eftdb.FunctionalTests/Eftdb.FunctionalTests.csproj @@ -1,42 +1,41 @@ - - net10.0 - enable - enable - CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests - CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests + + net10.0 + enable + enable + CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests + CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests - false - true - + false + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - + + + - - - - + + + + diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs index c6bb575..435f08d 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleMigrationsFixture.cs @@ -6,8 +6,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils { public class TimescaleMigrationsFixture : MigrationsInfrastructureFixtureBase, IAsyncLifetime { - private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg17") + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("migration_tests_db") .WithUsername(TimescaleConnectionHelper.Username) .WithPassword(TimescaleConnectionHelper.Password) diff --git a/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs index ea5bc7b..64af493 100644 --- a/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs +++ b/tests/Eftdb.FunctionalTests/Utils/TimescaleQueryFixture.cs @@ -6,8 +6,7 @@ namespace CmdScale.EntityFrameworkCore.TimescaleDB.FunctionalTests.Utils; public class TimescaleQueryFixture : IAsyncLifetime { - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg17") + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("query_tests_db") .WithUsername(TimescaleConnectionHelper.Username) .WithPassword(TimescaleConnectionHelper.Password) diff --git a/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs new file mode 100644 index 0000000..cd1f666 --- /dev/null +++ b/tests/Eftdb.Tests/Configuration/RetentionPolicyAttributeTests.cs @@ -0,0 +1,254 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify RetentionPolicyAttribute constructor validation, mutual exclusivity, and default values. +/// +public class RetentionPolicyAttributeTests +{ + #region Constructor1 Validation Tests (string dropAfter) + + [Fact] + public void Constructor1_With_Null_DropAfter_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new RetentionPolicyAttribute(null!)); + Assert.Contains("DropAfter must be provided", ex.Message); + Assert.Equal("dropAfter", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Empty_DropAfter_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new RetentionPolicyAttribute("")); + Assert.Contains("DropAfter must be provided", ex.Message); + Assert.Equal("dropAfter", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Whitespace_DropAfter_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new RetentionPolicyAttribute(" ")); + Assert.Contains("DropAfter must be provided", ex.Message); + Assert.Equal("dropAfter", ex.ParamName); + } + + [Fact] + public void Constructor1_With_Tabs_DropAfter_ThrowsArgumentException() + { + // Arrange & Act & Assert + ArgumentException ex = Assert.Throws(() => new RetentionPolicyAttribute("\t\t")); + Assert.Contains("DropAfter must be provided", ex.Message); + } + + [Fact] + public void Constructor1_With_Valid_DropAfter_SetsDropAfterCorrectly() + { + // Arrange & Act + RetentionPolicyAttribute attr = new("7 days"); + + // Assert + Assert.Equal("7 days", attr.DropAfter); + } + + #endregion + + #region Constructor2 Mutual Exclusivity Tests (string? dropAfter, string? dropCreatedBefore) + + [Fact] + public void Constructor2_With_DropAfterOnly_SetsDropAfterAndDropCreatedBeforeIsNull() + { + // Arrange & Act + RetentionPolicyAttribute attr = new(dropAfter: "7 days"); + + // Assert + Assert.Equal("7 days", attr.DropAfter); + Assert.Null(attr.DropCreatedBefore); + } + + [Fact] + public void Constructor2_With_DropCreatedBeforeOnly_SetsDropCreatedBeforeAndDropAfterIsNull() + { + // Arrange & Act + RetentionPolicyAttribute attr = new(dropCreatedBefore: "30 days"); + + // Assert + Assert.Null(attr.DropAfter); + Assert.Equal("30 days", attr.DropCreatedBefore); + } + + [Fact] + public void Constructor2_With_BothSpecified_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: "7 days", dropCreatedBefore: "30 days")); + Assert.Contains("mutually exclusive", ex.Message); + } + + [Fact] + public void Constructor2_With_NeitherSpecified_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: null, dropCreatedBefore: null)); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Default Values Tests + + [Fact] + public void Constructor1_With_Valid_DropAfter_SetsDefaultValues() + { + // Arrange & Act + RetentionPolicyAttribute attr = new("7 days"); + + // Assert + Assert.Equal(-1, attr.MaxRetries); + Assert.Null(attr.DropCreatedBefore); + Assert.Null(attr.InitialStart); + Assert.Null(attr.ScheduleInterval); + Assert.Null(attr.MaxRuntime); + Assert.Null(attr.RetryPeriod); + } + + #endregion + + #region Property Assignment Tests + + [Fact] + public void InitialStart_CanBeSet() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + InitialStart = "2025-01-01T00:00:00Z" + }; + + // Assert + Assert.Equal("2025-01-01T00:00:00Z", attr.InitialStart); + } + + [Fact] + public void ScheduleInterval_CanBeSet() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + ScheduleInterval = "1 day" + }; + + // Assert + Assert.Equal("1 day", attr.ScheduleInterval); + } + + [Fact] + public void MaxRuntime_CanBeSet() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + MaxRuntime = "1 hour" + }; + + // Assert + Assert.Equal("1 hour", attr.MaxRuntime); + } + + [Fact] + public void MaxRetries_CanBeSetToPositiveValue() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + MaxRetries = 5 + }; + + // Assert + Assert.Equal(5, attr.MaxRetries); + } + + [Fact] + public void MaxRetries_CanBeSetToZero() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + MaxRetries = 0 + }; + + // Assert + Assert.Equal(0, attr.MaxRetries); + } + + [Fact] + public void RetryPeriod_CanBeSet() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + RetryPeriod = "30 minutes" + }; + + // Assert + Assert.Equal("30 minutes", attr.RetryPeriod); + } + + [Fact] + public void AllProperties_CanBeSetTogether() + { + // Arrange + RetentionPolicyAttribute attr = new("7 days") + { + // Act + InitialStart = "2025-01-01T00:00:00Z", + ScheduleInterval = "1 day", + MaxRuntime = "1 hour", + MaxRetries = 3, + RetryPeriod = "30 minutes" + }; + + // Assert + Assert.Equal("7 days", attr.DropAfter); + Assert.Equal("2025-01-01T00:00:00Z", attr.InitialStart); + Assert.Equal("1 day", attr.ScheduleInterval); + Assert.Equal("1 hour", attr.MaxRuntime); + Assert.Equal(3, attr.MaxRetries); + Assert.Equal("30 minutes", attr.RetryPeriod); + } + + #endregion + + #region Constructor2 Empty and Whitespace String Tests + + [Fact] + public void Constructor2_With_Both_Empty_Strings_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: "", dropCreatedBefore: "")); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor2_With_Both_Whitespace_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + InvalidOperationException ex = Assert.Throws( + () => new RetentionPolicyAttribute(dropAfter: " ", dropCreatedBefore: " ")); + Assert.Contains("exactly one", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs b/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs new file mode 100644 index 0000000..1e25a1c --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/RetentionPolicyConventionTests.cs @@ -0,0 +1,683 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Conventions; + +/// +/// Tests that verify RetentionPolicyConvention processes [RetentionPolicy] attribute correctly +/// and applies the same annotations as the Fluent API. +/// +public class RetentionPolicyConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region Should_Process_Minimal_RetentionPolicy_Attribute + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days")] + private class MinimalRetentionPolicyEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MinimalRetentionPolicy"); + }); + } + } + + [Fact] + public void Should_Process_Minimal_RetentionPolicy_Attribute() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalRetentionPolicyEntity))!; + + Assert.NotNull(entityType); + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_DropCreatedBefore + + [Hypertable("Timestamp")] + [RetentionPolicy(dropCreatedBefore: "30 days")] + private class DropCreatedBeforeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("DropCreatedBefore"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_DropCreatedBefore() + { + using DropCreatedBeforeAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DropCreatedBeforeEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("30 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)?.Value); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_ScheduleInterval + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", ScheduleInterval = "1 day")] + private class ScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("ScheduleInterval"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_ScheduleInterval() + { + using ScheduleIntervalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScheduleIntervalEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_InitialStart + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", InitialStart = "2025-01-01T00:00:00Z")] + private class InitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("InitialStart"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_InitialStart() + { + using InitialStartAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(InitialStartEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + + object? initialStartValue = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + + DateTime initialStart = (DateTime)initialStartValue; + DateTime utcStart = initialStart.ToUniversalTime(); + Assert.Equal(2025, utcStart.Year); + Assert.Equal(1, utcStart.Month); + Assert.Equal(1, utcStart.Day); + Assert.Equal(0, utcStart.Hour); + Assert.Equal(0, utcStart.Minute); + Assert.Equal(0, utcStart.Second); + } + + #endregion + + #region Should_Throw_When_InitialStart_Has_Invalid_Format + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", InitialStart = "invalid-date-format")] + private class InvalidInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InvalidInitialStartContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("InvalidInitialStart"); + }); + } + } + + [Fact] + public void Should_Throw_When_InitialStart_Has_Invalid_Format() + { + InvalidOperationException exception = Assert.Throws(() => + { + using InvalidInitialStartContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("InitialStart", exception.Message); + Assert.Contains("not a valid DateTime format", exception.Message); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_MaxRetries + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", MaxRetries = 5)] + private class MaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MaxRetries"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_MaxRetries() + { + using MaxRetriesAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRetriesEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal(5, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + } + + #endregion + + #region Should_Not_Set_MaxRetries_When_Using_Default_Value + + [Fact] + public void Should_Not_Set_MaxRetries_When_Using_Default_Value() + { + using MinimalAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MinimalRetentionPolicyEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_MaxRuntime + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", MaxRuntime = "1 hour")] + private class MaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MaxRuntime"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_MaxRuntime() + { + using MaxRuntimeAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRuntimeEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_RetryPeriod + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", RetryPeriod = "30 minutes")] + private class RetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("RetryPeriod"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_RetryPeriod() + { + using RetryPeriodAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RetryPeriodEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("30 minutes", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region Should_Process_Fully_Configured_RetentionPolicy + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", ScheduleInterval = "1 day", InitialStart = "2025-01-01T00:00:00Z", MaxRuntime = "1 hour", MaxRetries = 3, RetryPeriod = "30 minutes")] + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("FullyConfigured"); + }); + } + } + + [Fact] + public void Should_Process_Fully_Configured_RetentionPolicy() + { + using FullyConfiguredAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("1 hour", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("30 minutes", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + + object? initialStartValue = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + } + + #endregion + + #region Should_Not_Process_Entity_Without_Attribute + + [Hypertable("Timestamp")] + private class PlainEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Plain"); + }); + } + } + + [Fact] + public void Should_Not_Process_Entity_Without_Attribute() + { + using NoAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(PlainEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)); + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)); + } + + #endregion + + #region Attribute_Should_Produce_Same_Annotations_As_FluentAPI + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", ScheduleInterval = "1 day", MaxRuntime = "1 hour")] + private class EquivalenceAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + [Hypertable("Timestamp")] + private class EquivalenceFluentEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AttributeBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + }); + } + } + + private class FluentApiBasedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Equivalence"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "1 day", + maxRuntime: "1 hour" + ); + }); + } + } + + [Fact] + public void Attribute_Should_Produce_Same_Annotations_As_FluentAPI() + { + using AttributeBasedContext attributeContext = new(); + using FluentApiBasedContext fluentContext = new(); + + IModel attributeModel = GetModel(attributeContext); + IModel fluentModel = GetModel(fluentContext); + + IEntityType attributeEntity = attributeModel.FindEntityType(typeof(EquivalenceAttributeEntity))!; + IEntityType fluentEntity = fluentModel.FindEntityType(typeof(EquivalenceFluentEntity))!; + + Assert.Equal( + attributeEntity.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value, + fluentEntity.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value, + fluentEntity.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value, + fluentEntity.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value + ); + Assert.Equal( + attributeEntity.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value, + fluentEntity.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value + ); + } + + #endregion + + #region Should_Process_RetentionPolicy_With_MaxRetries_Zero + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", MaxRetries = 0)] + private class MaxRetriesZeroEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesZeroAttributeContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("MaxRetriesZero"); + }); + } + } + + [Fact] + public void Should_Process_RetentionPolicy_With_MaxRetries_Zero() + { + using MaxRetriesZeroAttributeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRetriesZeroEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal(0, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + } + + #endregion + + #region Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified_Via_Attribute + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days", DropCreatedBefore = "30 days")] + private class BothSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BothSpecifiedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("BothSpecified"); + }); + } + } + + [Fact] + public void Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified_Via_Attribute() + { + InvalidOperationException exception = Assert.Throws(() => + { + using BothSpecifiedContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("mutually exclusive", exception.Message); + } + + #endregion + + #region Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified_Via_Attribute + + [Hypertable("Timestamp")] + [RetentionPolicy] + private class NoneSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoneSpecifiedContext : DbContext + { + public DbSet Entities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("NoneSpecified"); + }); + } + } + + [Fact] + public void Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified_Via_Attribute() + { + InvalidOperationException exception = Assert.Throws(() => + { + using NoneSpecifiedContext context = new(); + IModel model = GetModel(context); + }); + + Assert.Contains("RetentionPolicy: Exactly one of 'DropAfter' or 'DropCreatedBefore' must be specified.", exception.Message); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs new file mode 100644 index 0000000..f21b7ac --- /dev/null +++ b/tests/Eftdb.Tests/Differs/RetentionPolicyDifferTests.cs @@ -0,0 +1,1152 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Differs; + +public class RetentionPolicyDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + #region Should_Detect_New_RetentionPolicy + + private class MetricEntity1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext1 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class RetentionPolicyContext1 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Detect_New_RetentionPolicy() + { + using HypertableWithoutPolicyContext1 sourceContext = new(); + using RetentionPolicyContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AddRetentionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("Metrics", addOp.TableName); + Assert.Equal("7 days", addOp.DropAfter); + Assert.Equal("1 day", addOp.ScheduleInterval); + } + + #endregion + + #region Should_Detect_Multiple_New_Policies + + private class MetricEntity2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity2 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleHypertablesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class MultipleRetentionPoliciesContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_New_Policies() + { + using MultipleHypertablesContext2 sourceContext = new(); + using MultipleRetentionPoliciesContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List addOps = [.. operations.OfType()]; + Assert.Equal(2, addOps.Count); + Assert.Contains(addOps, op => op.TableName == "Metrics"); + Assert.Contains(addOps, op => op.TableName == "Logs"); + } + + #endregion + + #region Should_Detect_Policy_With_All_Custom_Parameters + + private class MetricEntity3 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HypertableWithoutPolicyContext3 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + private class FullyCustomRetentionPolicyContext3 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "14 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "12:00:00", + maxRuntime: "01:00:00", + maxRetries: 5, + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public void Should_Detect_Policy_With_All_Custom_Parameters() + { + using HypertableWithoutPolicyContext3 sourceContext = new(); + using FullyCustomRetentionPolicyContext3 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AddRetentionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal("14 days", addOp.DropAfter); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), addOp.InitialStart); + Assert.Equal("12:00:00", addOp.ScheduleInterval); + Assert.Equal("01:00:00", addOp.MaxRuntime); + Assert.Equal(5, addOp.MaxRetries); + Assert.Equal("00:10:00", addOp.RetryPeriod); + } + + #endregion + + #region Should_Detect_DropAfter_Change + + private class MetricEntity4 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext4 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class ModifiedDropAfterContext4 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "14 days"); // <-- Changed from "7 days" + }); + } + } + + [Fact] + public void Should_Detect_DropAfter_Change() + { + using RetentionPolicyContext4 sourceContext = new(); + using ModifiedDropAfterContext4 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("7 days", alterOp.OldDropAfter); + Assert.Equal("14 days", alterOp.DropAfter); + } + + #endregion + + #region Should_Detect_DropMethod_Switch_DropAfter_To_DropCreatedBefore + + private class MetricEntity5 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropAfterPolicyContext5 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class DropCreatedBeforePolicyContext5 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); // <-- Changed from dropAfter: "7 days" + }); + } + } + + [Fact] + public void Should_Detect_DropMethod_Switch_DropAfter_To_DropCreatedBefore() + { + using DropAfterPolicyContext5 sourceContext = new(); + using DropCreatedBeforePolicyContext5 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("7 days", alterOp.OldDropAfter); + Assert.Null(alterOp.DropAfter); + Assert.Null(alterOp.OldDropCreatedBefore); + Assert.Equal("30 days", alterOp.DropCreatedBefore); + } + + #endregion + + #region Should_Detect_ScheduleInterval_Change + + private class MetricEntity6 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext6 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class ModifiedScheduleIntervalContext6 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "12:00:00" // <-- Changed from default "1 day" + ); + }); + } + } + + [Fact] + public void Should_Detect_ScheduleInterval_Change() + { + using RetentionPolicyContext6 sourceContext = new(); + using ModifiedScheduleIntervalContext6 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("1 day", alterOp.OldScheduleInterval); + Assert.Equal("12:00:00", alterOp.ScheduleInterval); + } + + #endregion + + #region Should_Detect_MaxRetries_Change + + private class MetricEntity7 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext7 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRetries: 5 + ); + }); + } + } + + private class ModifiedMaxRetriesContext7 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRetries: 10 // <-- Changed from 5 + ); + }); + } + } + + [Fact] + public void Should_Detect_MaxRetries_Change() + { + using RetentionPolicyContext7 sourceContext = new(); + using ModifiedMaxRetriesContext7 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(5, alterOp.OldMaxRetries); + Assert.Equal(10, alterOp.MaxRetries); + } + + #endregion + + #region Should_Detect_InitialStart_Change + + private class MetricEntity8 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext8 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + private class ModifiedInitialStartContext8 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc) // <-- Changed from 2025-01-01 + ); + }); + } + } + + [Fact] + public void Should_Detect_InitialStart_Change() + { + using RetentionPolicyContext8 sourceContext = new(); + using ModifiedInitialStartContext8 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), alterOp.OldInitialStart); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), alterOp.InitialStart); + } + + #endregion + + #region Should_Detect_Multiple_Parameter_Changes + + private class MetricEntity9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext9 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class FullyCustomRetentionPolicyContext9 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "30 days", // <-- Changed from "7 days" + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", // <-- Changed from default "1 day" + maxRuntime: "02:00:00", // <-- Changed from default "00:00:00" + maxRetries: 3, // <-- Changed from default -1 + retryPeriod: "00:15:00" // <-- Changed from default "1 day" + ); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Parameter_Changes() + { + using RetentionPolicyContext9 sourceContext = new(); + using FullyCustomRetentionPolicyContext9 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.NotEqual(alterOp.OldDropAfter, alterOp.DropAfter); + Assert.NotEqual(alterOp.OldScheduleInterval, alterOp.ScheduleInterval); + Assert.NotEqual(alterOp.OldMaxRuntime, alterOp.MaxRuntime); + Assert.NotEqual(alterOp.OldMaxRetries, alterOp.MaxRetries); + Assert.NotEqual(alterOp.OldRetryPeriod, alterOp.RetryPeriod); + } + + #endregion + + #region Should_Detect_Dropped_RetentionPolicy + + private class MetricEntity10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext10 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class HypertableWithoutPolicyContext10 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Dropped_RetentionPolicy() + { + using RetentionPolicyContext10 sourceContext = new(); + using HypertableWithoutPolicyContext10 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + DropRetentionPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + Assert.Equal("Metrics", dropOp.TableName); + } + + #endregion + + #region Should_Detect_Multiple_Dropped_Policies + + private class MetricEntity11 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class LogEntity11 + { + public DateTime Timestamp { get; set; } + public string? Message { get; set; } + } + + private class MultipleRetentionPoliciesContext11 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + private class MultipleHypertablesContext11 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Logs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Logs"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Detect_Multiple_Dropped_Policies() + { + using MultipleRetentionPoliciesContext11 sourceContext = new(); + using MultipleHypertablesContext11 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + List dropOps = [.. operations.OfType()]; + Assert.Equal(2, dropOps.Count); + } + + #endregion + + #region Should_Not_Generate_Operations_When_No_Changes + + private class MetricEntity12 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext12 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Not_Generate_Operations_When_No_Changes() + { + using RetentionPolicyContext12 sourceContext = new(); + using RetentionPolicyContext12 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Handle_Null_Source_Model + + private class MetricEntity13 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext13 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Source_Model() + { + using RetentionPolicyContext13 targetContext = new(); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, targetModel); + + AddRetentionPolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + } + + #endregion + + #region Should_Handle_Null_Target_Model + + private class MetricEntity14 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext14 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Handle_Null_Target_Model() + { + using RetentionPolicyContext14 sourceContext = new(); + IRelationalModel sourceModel = GetModel(sourceContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, null); + + DropRetentionPolicyOperation? dropOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(dropOp); + } + + #endregion + + #region Should_Handle_Both_Null_Models + + [Fact] + public void Should_Handle_Both_Null_Models() + { + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(null, null); + + Assert.Empty(operations); + } + + #endregion + + #region Should_Detect_MaxRuntime_Change + + private class MetricEntity15 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext15 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRuntime: "00:30:00" + ); + }); + } + } + + private class ModifiedMaxRuntimeContext15 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRuntime: "02:00:00" // <-- Changed from "00:30:00" + ); + }); + } + } + + [Fact] + public void Should_Detect_MaxRuntime_Change() + { + using RetentionPolicyContext15 sourceContext = new(); + using ModifiedMaxRuntimeContext15 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("00:30:00", alterOp.OldMaxRuntime); + Assert.Equal("02:00:00", alterOp.MaxRuntime); + } + + #endregion + + #region Should_Detect_RetryPeriod_Change + + private class MetricEntity16 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyContext16 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + retryPeriod: "00:05:00" + ); + }); + } + } + + private class ModifiedRetryPeriodContext16 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + retryPeriod: "00:30:00" // <-- Changed from "00:05:00" + ); + }); + } + } + + [Fact] + public void Should_Detect_RetryPeriod_Change() + { + using RetentionPolicyContext16 sourceContext = new(); + using ModifiedRetryPeriodContext16 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + + RetentionPolicyDiffer differ = new(); + + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal("00:05:00", alterOp.OldRetryPeriod); + Assert.Equal("00:30:00", alterOp.RetryPeriod); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Eftdb.Tests.csproj b/tests/Eftdb.Tests/Eftdb.Tests.csproj index e351fa9..176ef06 100644 --- a/tests/Eftdb.Tests/Eftdb.Tests.csproj +++ b/tests/Eftdb.Tests/Eftdb.Tests.csproj @@ -1,40 +1,40 @@  - - net10.0 - enable - enable - CmdScale.EntityFrameworkCore.TimescaleDB.Tests - CmdScale.EntityFrameworkCore.TimescaleDB.Tests + + net10.0 + enable + enable + CmdScale.EntityFrameworkCore.TimescaleDB.Tests + CmdScale.EntityFrameworkCore.TimescaleDB.Tests - false - true - + false + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - + + + - - - - + + + + diff --git a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs index 49ec497..5da7775 100644 --- a/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs +++ b/tests/Eftdb.Tests/Extensions/BulkCopyExtensionsTests.cs @@ -16,10 +16,9 @@ public class BulkCopyExtensionsTests : IAsyncLifetime private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -29,8 +28,10 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { + GC.SuppressFinalize(this); + if (_container != null) { await _container.DisposeAsync(); @@ -72,7 +73,7 @@ public async Task Should_BulkCopy_With_Default_Config() { // Arrange using DefaultConfigContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = [ @@ -85,10 +86,10 @@ public async Task Should_BulkCopy_With_Default_Config() await data.BulkCopyAsync(_connectionString!); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(3, count); - List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(); + List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(TestContext.Current.CancellationToken); Assert.Equal("Test1", inserted[0].Name); Assert.Equal("Test2", inserted[1].Name); Assert.Equal("Test3", inserted[2].Name); @@ -129,7 +130,7 @@ public async Task Should_BulkCopy_With_Custom_Table_Name() { // Arrange using CustomTableContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = [ @@ -144,7 +145,7 @@ public async Task Should_BulkCopy_With_Custom_Table_Name() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(2, count); } @@ -183,7 +184,7 @@ public async Task Should_BulkCopy_With_Custom_Workers_And_BatchSize() { // Arrange using WorkerConfigContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = []; for (int i = 1; i <= 100; i++) @@ -200,7 +201,7 @@ public async Task Should_BulkCopy_With_Custom_Workers_And_BatchSize() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(100, count); } @@ -241,7 +242,7 @@ public async Task Should_BulkCopy_With_Manual_Column_Mapping() { // Arrange using MappingContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = [ @@ -259,10 +260,10 @@ public async Task Should_BulkCopy_With_Manual_Column_Mapping() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(2, count); - List inserted = await context.Set().OrderBy(e => e.Identifier).ToListAsync(); + List inserted = await context.Set().OrderBy(e => e.Identifier).ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(100.50m, inserted[0].Amount); Assert.Equal(200.75m, inserted[1].Amount); } @@ -312,7 +313,7 @@ public async Task Should_BulkCopy_Various_Data_Types() { // Arrange using DataTypeContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); Guid testGuid = Guid.NewGuid(); DateTime testDateTime = DateTime.UtcNow; @@ -340,7 +341,7 @@ public async Task Should_BulkCopy_Various_Data_Types() await data.BulkCopyAsync(_connectionString!); // Assert - DataTypeEntity? inserted = await context.Set().FirstOrDefaultAsync(); + DataTypeEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(42, inserted.IntValue); Assert.Equal(9223372036854775807L, inserted.LongValue); @@ -391,7 +392,7 @@ public async Task Should_BulkCopy_To_Hypertable() { // Arrange using HypertableContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); DateTime baseTime = DateTime.UtcNow; List data = @@ -410,14 +411,14 @@ public async Task Should_BulkCopy_To_Hypertable() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(3, count); // Verify data integrity List inserted = await context.Set() .OrderBy(e => e.Timestamp) .ThenBy(e => e.SensorId) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(22.5, inserted[0].Temperature); Assert.Equal(45.0, inserted[0].Humidity); } @@ -457,7 +458,7 @@ public async Task Should_BulkCopy_Empty_Collection() { // Arrange using EmptyCollectionContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = []; @@ -465,7 +466,7 @@ public async Task Should_BulkCopy_Empty_Collection() await data.BulkCopyAsync(_connectionString!); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(0, count); } @@ -504,7 +505,7 @@ public async Task Should_BulkCopy_Single_Item() { // Arrange using SingleItemContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = [ @@ -515,10 +516,10 @@ public async Task Should_BulkCopy_Single_Item() await data.BulkCopyAsync(_connectionString!); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(1, count); - SingleItemEntity? inserted = await context.Set().FirstOrDefaultAsync(); + SingleItemEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal("OnlyOne", inserted.Value); } @@ -559,7 +560,7 @@ public async Task Should_BulkCopy_Large_Dataset() { // Arrange using LargeDatasetContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = []; DateTime baseTime = DateTime.UtcNow; @@ -582,7 +583,7 @@ public async Task Should_BulkCopy_Large_Dataset() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(10000, count); } @@ -624,7 +625,7 @@ public async Task Should_BulkCopy_With_Nullable_Types() { // Arrange using NullableTypeContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); List data = [ @@ -636,10 +637,10 @@ public async Task Should_BulkCopy_With_Nullable_Types() await data.BulkCopyAsync(_connectionString!); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(2, count); - List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(); + List inserted = await context.Set().OrderBy(e => e.Id).ToListAsync(TestContext.Current.CancellationToken); // First record with values Assert.Equal(42, inserted[0].NullableInt); @@ -691,7 +692,7 @@ public async Task Should_BulkCopy_With_DateOnly_And_TimeOnly() { // Arrange using DateTimeOnlyContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); DateOnly testDate = new(2024, 3, 15); TimeOnly testTime = new(14, 30, 45); @@ -705,7 +706,7 @@ public async Task Should_BulkCopy_With_DateOnly_And_TimeOnly() await data.BulkCopyAsync(_connectionString!); // Assert - DateTimeOnlyEntity? inserted = await context.Set().FirstOrDefaultAsync(); + DateTimeOnlyEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(testDate, inserted.DateValue); Assert.Equal(testTime, inserted.TimeValue); @@ -745,7 +746,7 @@ public async Task Should_BulkCopy_With_TimeSpan() { // Arrange using TimeSpanContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); TimeSpan testDuration = new(2, 30, 45); // 2 hours, 30 minutes, 45 seconds @@ -758,7 +759,7 @@ public async Task Should_BulkCopy_With_TimeSpan() await data.BulkCopyAsync(_connectionString!); // Assert - TimeSpanEntity? inserted = await context.Set().FirstOrDefaultAsync(); + TimeSpanEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(testDuration, inserted.Duration); } @@ -798,7 +799,7 @@ public async Task Should_BulkCopy_With_Guid() { // Arrange using GuidContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); Guid testGuid = Guid.NewGuid(); @@ -811,7 +812,7 @@ public async Task Should_BulkCopy_With_Guid() await data.BulkCopyAsync(_connectionString!); // Assert - GuidEntity? inserted = await context.Set().FirstOrDefaultAsync(); + GuidEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(testGuid, inserted.Id); Assert.Equal("GuidTest", inserted.Name); @@ -852,7 +853,7 @@ public async Task Should_BulkCopy_With_Multiple_Workers_Small_Dataset() { // Arrange using MultiWorkerSmallContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); // Only 3 items but 10 workers - should handle gracefully List data = @@ -870,7 +871,7 @@ public async Task Should_BulkCopy_With_Multiple_Workers_Small_Dataset() await data.BulkCopyAsync(_connectionString!, config); // Assert - int count = await context.Set().CountAsync(); + int count = await context.Set().CountAsync(TestContext.Current.CancellationToken); Assert.Equal(3, count); } @@ -909,7 +910,7 @@ public async Task Should_BulkCopy_With_Byte_Array() { // Arrange using ByteArrayContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); byte[] testBytes = [0xFF, 0xAA, 0x55, 0x00, 0x11, 0x22, 0x33, 0x44]; @@ -922,7 +923,7 @@ public async Task Should_BulkCopy_With_Byte_Array() await data.BulkCopyAsync(_connectionString!); // Assert - ByteArrayEntity? inserted = await context.Set().FirstOrDefaultAsync(); + ByteArrayEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(testBytes, inserted.BinaryData); } @@ -963,7 +964,7 @@ public async Task Should_BulkCopy_Respecting_Column_Order() { // Arrange using ColumnOrderContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); DateTime testTime = DateTime.UtcNow; @@ -983,7 +984,7 @@ public async Task Should_BulkCopy_Respecting_Column_Order() await data.BulkCopyAsync(_connectionString!, config); // Assert - ColumnOrderEntity? inserted = await context.Set().FirstOrDefaultAsync(); + ColumnOrderEntity? inserted = await context.Set().FirstOrDefaultAsync(TestContext.Current.CancellationToken); Assert.NotNull(inserted); Assert.Equal(100, inserted.Column1); Assert.Equal("Test", inserted.Column3); diff --git a/tests/Eftdb.Tests/Extractors/RetentionPolicyModelExtractorTests.cs b/tests/Eftdb.Tests/Extractors/RetentionPolicyModelExtractorTests.cs new file mode 100644 index 0000000..ae52d43 --- /dev/null +++ b/tests/Eftdb.Tests/Extractors/RetentionPolicyModelExtractorTests.cs @@ -0,0 +1,1025 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.RetentionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Extractors; + +/// +/// Tests that verify RetentionPolicyModelExtractor correctly extracts retention policy configurations +/// from EF Core models and converts them to AddRetentionPolicyOperation objects. +/// +public class RetentionPolicyModelExtractorTests +{ + private static IRelationalModel GetRelationalModel(DbContext context) + { + IModel model = context.GetService().Model; + return model.GetRelationalModel(); + } + + #region Should_Extract_Minimal_RetentionPolicy_With_DropAfter + + private class MinimalDropAfterMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MinimalDropAfterContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_Minimal_RetentionPolicy_With_DropAfter() + { + using MinimalDropAfterContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Equal("7 days", operation.DropAfter); + Assert.Null(operation.DropCreatedBefore); + Assert.Null(operation.InitialStart); + Assert.Equal(DefaultValues.RetentionPolicyScheduleInterval, operation.ScheduleInterval); + Assert.Equal(DefaultValues.RetentionPolicyMaxRuntime, operation.MaxRuntime); + Assert.Equal(DefaultValues.RetentionPolicyMaxRetries, operation.MaxRetries); + Assert.Equal(DefaultValues.RetentionPolicyScheduleInterval, operation.RetryPeriod); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_DropCreatedBefore + + private class DropCreatedBeforeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropCreatedBefore: "30 days"); + }); + } + } + + [Fact] + public void Should_Extract_RetentionPolicy_With_DropCreatedBefore() + { + using DropCreatedBeforeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Null(operation.DropAfter); + Assert.Equal("30 days", operation.DropCreatedBefore); + } + + #endregion + + #region Should_Return_Empty_When_RelationalModel_Is_Null + + [Fact] + public void Should_Return_Empty_When_RelationalModel_Is_Null() + { + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(null)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Return_Empty_When_No_RetentionPolicies + + private class NoRetentionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public void Should_Return_Empty_When_No_RetentionPolicies() + { + using NoRetentionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Extract_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public void Should_Extract_InitialStart() + { + using InitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + DateTime? initialStart = operations[0].InitialStart; + Assert.NotNull(initialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, initialStart.Value); + } + + #endregion + + #region Should_Have_Null_InitialStart_When_Not_Specified + + private class NullInitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NullInitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Have_Null_InitialStart_When_Not_Specified() + { + using NullInitialStartContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Null(operations[0].InitialStart); + } + + #endregion + + #region Should_Extract_ScheduleInterval + + private class ScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "12:00:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_ScheduleInterval() + { + using ScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("12:00:00", operations[0].ScheduleInterval); + } + + #endregion + + #region Should_Use_Default_ScheduleInterval_When_Not_Specified + + private class DefaultScheduleIntervalMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Use_Default_ScheduleInterval_When_Not_Specified() + { + using DefaultScheduleIntervalContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.RetentionPolicyScheduleInterval, operations[0].ScheduleInterval); + } + + #endregion + + #region Should_Extract_MaxRuntime + + private class MaxRuntimeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_MaxRuntime() + { + using MaxRuntimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("01:00:00", operations[0].MaxRuntime); + } + + #endregion + + #region Should_Use_Default_MaxRuntime_When_Not_Specified + + private class DefaultMaxRuntimeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Use_Default_MaxRuntime_When_Not_Specified() + { + using DefaultMaxRuntimeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.RetentionPolicyMaxRuntime, operations[0].MaxRuntime); + } + + #endregion + + #region Should_Extract_MaxRetries + + private class MaxRetriesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public void Should_Extract_MaxRetries() + { + using MaxRetriesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(5, operations[0].MaxRetries); + } + + #endregion + + #region Should_Use_Default_MaxRetries_When_Not_Specified + + private class DefaultMaxRetriesMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultMaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Use_Default_MaxRetries_When_Not_Specified() + { + using DefaultMaxRetriesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.RetentionPolicyMaxRetries, operations[0].MaxRetries); + } + + #endregion + + #region Should_Extract_RetryPeriod + + private class RetryPeriodMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_RetryPeriod() + { + using RetryPeriodContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("00:10:00", operations[0].RetryPeriod); + } + + #endregion + + #region Should_Use_Default_RetryPeriod_When_Not_Specified + + private class DefaultRetryPeriodMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DefaultRetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Use_Default_RetryPeriod_When_Not_Specified() + { + using DefaultRetryPeriodContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal(DefaultValues.RetentionPolicyScheduleInterval, operations[0].RetryPeriod); + } + + #endregion + + #region Should_Extract_Multiple_RetentionPolicies + + private class MultipleMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultipleEvent + { + public DateTime Timestamp { get; set; } + public string EventType { get; set; } = string.Empty; + } + + private class MultiplePoliciesContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Events => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Events"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "14 days"); + }); + } + } + + [Fact] + public void Should_Extract_Multiple_RetentionPolicies() + { + using MultiplePoliciesContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Equal(2, operations.Count); + Assert.Contains(operations, op => op.TableName == "Metrics"); + Assert.Contains(operations, op => op.TableName == "Events"); + } + + #endregion + + #region Should_Extract_Fully_Configured_RetentionPolicy + + private class FullyConfiguredMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "30 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "06:00:00", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + }); + } + } + + [Fact] + public void Should_Extract_Fully_Configured_RetentionPolicy() + { + using FullyConfiguredContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("public", operation.Schema); + Assert.Equal("30 days", operation.DropAfter); + Assert.Null(operation.DropCreatedBefore); + Assert.NotNull(operation.InitialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, operation.InitialStart.Value); + Assert.Equal("06:00:00", operation.ScheduleInterval); + Assert.Equal("02:00:00", operation.MaxRuntime); + Assert.Equal(3, operation.MaxRetries); + Assert.Equal("00:15:00", operation.RetryPeriod); + } + + #endregion + + #region Should_Extract_RetentionPolicy_From_Attribute + + [Hypertable("Timestamp")] + [RetentionPolicy("7 days")] + private class RetentionPolicyAttributeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionPolicyAttributeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + }); + } + } + + [Fact] + public void Should_Extract_RetentionPolicy_From_Attribute() + { + using RetentionPolicyAttributeContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("Metrics", operation.TableName); + Assert.Equal("7 days", operation.DropAfter); + } + + #endregion + + #region Should_Extract_RetentionPolicy_From_View + + private class ViewRetentionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ViewRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("my_view"); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_RetentionPolicy_From_View() + { + using ViewRetentionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("my_view", operation.TableName); + Assert.Equal("7 days", operation.DropAfter); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_ViewSchema + + private class ViewSchemaRetentionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ViewSchemaRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("my_view", "analytics"); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_RetentionPolicy_With_ViewSchema() + { + using ViewSchemaRetentionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + AddRetentionPolicyOperation operation = operations[0]; + Assert.Equal("my_view", operation.TableName); + Assert.Equal("analytics", operation.Schema); + } + + #endregion + + #region Should_Use_DefaultSchema_When_No_Schema_Specified + + private class ViewDefaultSchemaRetentionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ViewDefaultSchemaRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToView("my_view"); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Use_DefaultSchema_When_No_Schema_Specified() + { + using ViewDefaultSchemaRetentionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("public", operations[0].Schema); + } + + #endregion + + #region Should_Extract_Custom_Schema + + private class CustomSchemaRetentionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics", "custom_schema"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void Should_Extract_Custom_Schema() + { + using CustomSchemaRetentionPolicyContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Single(operations); + Assert.Equal("custom_schema", operations[0].Schema); + } + + #endregion + + #region Should_Skip_Entity_With_HasRetentionPolicy_But_No_Drop_Values + + private class HasRetentionNoDropMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HasRetentionNoDropContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("HasRetentionNoDrop"); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + }); + } + } + + [Fact] + public void Should_Skip_Entity_With_HasRetentionPolicy_But_No_Drop_Values() + { + using HasRetentionNoDropContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion + + #region Should_Skip_Entity_With_No_Table_Or_View_Name + + private class NoTableNameMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoTableNameContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable((string?)null); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + }); + } + } + + [Fact] + public void Should_Skip_Entity_With_No_Table_Or_View_Name() + { + using NoTableNameContext context = new(); + IRelationalModel relationalModel = GetRelationalModel(context); + + List operations = [.. RetentionPolicyModelExtractor.GetRetentionPolicies(relationalModel)]; + + Assert.Empty(operations); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs new file mode 100644 index 0000000..ffe29e8 --- /dev/null +++ b/tests/Eftdb.Tests/Generators/RetentionPolicyOperationGeneratorTests.cs @@ -0,0 +1,606 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Generators; +using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; +using CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Utils; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Generators +{ + public class RetentionPolicyOperationGeneratorTests + { + /// + /// A helper to run the generator and capture its string output. + /// + private static string GetGeneratedCode(dynamic operation) + { + IndentedStringBuilder builder = new(); + RetentionPolicyOperationGenerator generator = new(true); + List statements = generator.Generate(operation); + SqlBuilderHelper.BuildQueryString(statements, builder); + return builder.ToString(); + } + + // --- Tests for AddRetentionPolicyOperation --- + + #region Generate_Add_DropAfter_with_minimal_config_creates_only_add_policy_sql + + [Fact] + public void Generate_Add_DropAfter_with_minimal_config_creates_only_add_policy_sql() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days" + }; + + string expected = @".Sql(@"" + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_DropCreatedBefore_creates_add_policy_without_alter_job + + [Fact] + public void Generate_Add_DropCreatedBefore_creates_add_policy_without_alter_job() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropCreatedBefore = "30 days", + ScheduleInterval = "1 day", + MaxRetries = 5 + }; + + // DropCreatedBefore policies must not emit alter_job due to TimescaleDB bug #9446. + // Job settings are intentionally ignored. + string expected = @".Sql(@"" + SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_with_InitialStart_includes_iso_8601_timestamp + + [Fact] + public void Generate_Add_with_InitialStart_includes_iso_8601_timestamp() + { + // Arrange + DateTime testDate = new(2025, 10, 20, 12, 30, 0, DateTimeKind.Utc); + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + InitialStart = testDate + }; + + string expected = @".Sql(@"" + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days', initial_start => '2025-10-20T12:30:00.0000000Z'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_DropAfter_with_all_job_settings_creates_add_and_alter_job_sql + + [Fact] + public void Generate_Add_DropAfter_with_all_job_settings_creates_add_and_alter_job_sql() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + ScheduleInterval = "2 days", + MaxRuntime = "1 hour", + MaxRetries = 5, + RetryPeriod = "10 minutes" + }; + + string expected = @".Sql(@"" + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days'); + SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_runtime => INTERVAL '1 hour', max_retries => 5, retry_period => INTERVAL '10 minutes') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Add_DropCreatedBefore_with_job_settings_still_omits_alter_job + + [Fact] + public void Generate_Add_DropCreatedBefore_with_job_settings_still_omits_alter_job() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropCreatedBefore = "30 days", + ScheduleInterval = "2 days", + MaxRuntime = "1 hour", + MaxRetries = 5, + RetryPeriod = "10 minutes" + }; + + // Even with all job settings specified, alter_job is omitted for DropCreatedBefore + // due to TimescaleDB bug #9446 workaround. + string expected = @".Sql(@"" + SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // --- Tests for AlterRetentionPolicyOperation --- + + #region Generate_Alter_when_only_job_settings_change_creates_only_alter_job_sql + + [Fact] + public void Generate_Alter_when_only_job_settings_change_creates_only_alter_job_sql() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "metrics", + TableName = "TestTable", + DropAfter = "7 days", + OldDropAfter = "7 days", + DropCreatedBefore = null, + OldDropCreatedBefore = null, + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "2 days", + OldScheduleInterval = "1 day" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'metrics' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_when_DropAfter_changes_creates_remove_add_and_alter_job_sql + + [Fact] + public void Generate_Alter_when_DropAfter_changes_creates_remove_add_and_alter_job_sql() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "14 days", // <-- Changed from "7 days" + OldDropAfter = "7 days", + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day" + }; + + string expected = @".Sql(@"" + SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '14 days'); + SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_changed_to_DropCreatedBefore_creates_remove_and_add_without_alter_job + + [Fact] + public void Generate_Alter_changed_to_DropCreatedBefore_creates_remove_and_add_without_alter_job() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = null, // <-- Changed from "7 days" + OldDropAfter = "7 days", + DropCreatedBefore = "30 days", // <-- Changed from null + OldDropCreatedBefore = null, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day" + }; + + // During Alter recreation, alter_job is still emitted to reapply existing job settings. + // The DropCreatedBefore workaround only applies to the Add path. + string expected = @".Sql(@"" + SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); + SELECT add_retention_policy('public.""""TestTable""""', drop_created_before => INTERVAL '30 days'); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_when_InitialStart_changes_creates_remove_add_and_alter_job_sql + + [Fact] + public void Generate_Alter_when_InitialStart_changes_creates_remove_add_and_alter_job_sql() + { + // Arrange + DateTime oldDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime newDate = new(2025, 6, 15, 12, 0, 0, DateTimeKind.Utc); + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + OldDropAfter = "7 days", + InitialStart = newDate, // <-- Changed from oldDate + OldInitialStart = oldDate, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day" + }; + + string expected = @".Sql(@"" + SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '7 days', initial_start => '2025-06-15T12:00:00.0000000Z'); + SELECT alter_job(job_id, schedule_interval => INTERVAL '1 day') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // --- Tests for DropRetentionPolicyOperation --- + + #region Generate_Drop_creates_correct_remove_policy_sql + + [Fact] + public void Generate_Drop_creates_correct_remove_policy_sql() + { + // Arrange + DropRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable" + }; + + string expected = @".Sql(@"" + SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Drop_with_custom_schema_uses_correct_regclass_quoting + + [Fact] + public void Generate_Drop_with_custom_schema_uses_correct_regclass_quoting() + { + // Arrange + DropRetentionPolicyOperation operation = new() + { + Schema = "analytics", + TableName = "EventLogs" + }; + + string expected = @".Sql(@"" + SELECT remove_retention_policy('analytics.""""EventLogs""""', if_exists => true); + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_when_both_fundamental_and_job_settings_change_creates_full_sequence + + [Fact] + public void Generate_Alter_when_both_fundamental_and_job_settings_change_creates_full_sequence() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "14 days", // <-- Changed from "7 days" + OldDropAfter = "7 days", + ScheduleInterval = "2 days", // <-- Changed from "1 day" + OldScheduleInterval = "1 day", + MaxRetries = 5, // <-- Changed from default + OldMaxRetries = -1, + RetryPeriod = "10 minutes", + OldRetryPeriod = "10 minutes" + }; + + string expected = @".Sql(@"" + SELECT remove_retention_policy('public.""""TestTable""""', if_exists => true); + SELECT add_retention_policy('public.""""TestTable""""', drop_after => INTERVAL '14 days'); + SELECT alter_job(job_id, schedule_interval => INTERVAL '2 days', max_retries => 5, retry_period => INTERVAL '10 minutes') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_DropCreatedBefore_job_settings_change_skips_alter_job + + [Fact] + public void Generate_Alter_DropCreatedBefore_job_settings_change_skips_alter_job() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = null, + OldDropAfter = null, + DropCreatedBefore = "30 days", + OldDropCreatedBefore = "30 days", + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "2 days", // <-- Changed from "1 day" + OldScheduleInterval = "1 day" + }; + + // No recreation needed, but alter_job is skipped for DropCreatedBefore due to TimescaleDB bug #9446. + RetentionPolicyOperationGenerator generator = new(true); + List result = generator.Generate(operation); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region Generate_Alter_MaxRuntime_change_emits_alter_job + + [Fact] + public void Generate_Alter_MaxRuntime_change_emits_alter_job() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + OldDropAfter = "7 days", + DropCreatedBefore = null, + OldDropCreatedBefore = null, + InitialStart = null, + OldInitialStart = null, + MaxRuntime = "2 hours", // <-- Changed from "1 hour" + OldMaxRuntime = "1 hour", + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day", + MaxRetries = -1, + OldMaxRetries = -1, + RetryPeriod = "1 day", + OldRetryPeriod = "1 day" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, max_runtime => INTERVAL '2 hours') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + #region Generate_Alter_RetryPeriod_change_emits_alter_job + + [Fact] + public void Generate_Alter_RetryPeriod_change_emits_alter_job() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + OldDropAfter = "7 days", + DropCreatedBefore = null, + OldDropCreatedBefore = null, + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day", + MaxRuntime = "00:00:00", + OldMaxRuntime = "00:00:00", + MaxRetries = -1, + OldMaxRetries = -1, + RetryPeriod = "30 minutes", // <-- Changed from "1 day" + OldRetryPeriod = "1 day" + }; + + string expected = @".Sql(@"" + SELECT alter_job(job_id, retry_period => INTERVAL '30 minutes') + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' AND hypertable_schema = 'public' AND hypertable_name = 'TestTable'; + "")"; + + // Act + string result = GetGeneratedCode(operation); + + // Assert + Assert.Equal(SqlHelper.NormalizeSql(expected), SqlHelper.NormalizeSql(result)); + } + + #endregion + + // --- Tests for runtime quoting (isDesignTime=false) --- + + private static List GetRuntimeStatements(dynamic operation) + { + RetentionPolicyOperationGenerator generator = new(isDesignTime: false); + return generator.Generate(operation); + } + + #region Generate_Add_DropAfter_with_runtime_quoting_uses_single_quotes + + [Fact] + public void Generate_Add_DropAfter_with_runtime_quoting_uses_single_quotes() + { + // Arrange + AddRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days" + }; + + // Act + List statements = GetRuntimeStatements(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("'public.\"TestTable\"'", statements[0]); + } + + #endregion + + #region Generate_Drop_with_runtime_quoting_uses_single_quotes + + [Fact] + public void Generate_Drop_with_runtime_quoting_uses_single_quotes() + { + // Arrange + DropRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable" + }; + + // Act + List statements = GetRuntimeStatements(operation); + + // Assert + Assert.Single(statements); + Assert.Contains("'public.\"TestTable\"'", statements[0]); + } + + #endregion + + // --- Tests for alter no-change path --- + + #region Generate_Alter_when_no_changes_returns_empty_list + + [Fact] + public void Generate_Alter_when_no_changes_returns_empty_list() + { + // Arrange + AlterRetentionPolicyOperation operation = new() + { + Schema = "public", + TableName = "TestTable", + DropAfter = "7 days", + OldDropAfter = "7 days", + DropCreatedBefore = null, + OldDropCreatedBefore = null, + InitialStart = null, + OldInitialStart = null, + ScheduleInterval = "1 day", + OldScheduleInterval = "1 day", + MaxRuntime = "00:00:00", + OldMaxRuntime = "00:00:00", + MaxRetries = -1, + OldMaxRetries = -1, + RetryPeriod = "1 day", + OldRetryPeriod = "1 day" + }; + + // Act + RetentionPolicyOperationGenerator generator = new(true); + List result = generator.Generate(operation); + + // Assert + Assert.Empty(result); + } + + #endregion + } +} diff --git a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs index 03156e1..7b90529 100644 --- a/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs +++ b/tests/Eftdb.Tests/Generators/TimescaleCSharpMigrationOperationGeneratorTests.cs @@ -641,6 +641,138 @@ public void Generate_RemoveContinuousAggregatePolicy_WithIfExists_GeneratesValid #endregion + #region RetentionPolicyOperation Tests + + [Fact] + public void Generate_AddRetentionPolicy_WithDropAfter_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddRetentionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + DropAfter = "30 days" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("migrationBuilder", result); + Assert.Contains(".Sql(@\"", result); + Assert.Contains("add_retention_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AddRetentionPolicy_WithDropCreatedBefore_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AddRetentionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + DropCreatedBefore = "60 days" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("add_retention_policy", result); + Assert.DoesNotContain("alter_job", result); + } + + [Fact] + public void Generate_AlterRetentionPolicy_WithDropAfterChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterRetentionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + DropAfter = "60 days", + OldDropAfter = "30 days" // <-- Changed from 30 days + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("remove_retention_policy", result); + Assert.Contains("add_retention_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_AlterRetentionPolicy_WithScheduleIntervalChange_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + AlterRetentionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public", + DropAfter = "30 days", + OldDropAfter = "30 days", // Same drop_after + ScheduleInterval = "1 day", + OldScheduleInterval = "4 days" // <-- Changed from 4 days + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("alter_job", result); + Assert.DoesNotContain("remove_retention_policy", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + [Fact] + public void Generate_DropRetentionPolicy_GeneratesValidCSharp() + { + // Arrange + CSharpMigrationOperationGeneratorDependencies dependencies = CreateDependencies(); + TimescaleCSharpMigrationOperationGenerator generator = new(dependencies); + IndentedStringBuilder builder = new(); + + DropRetentionPolicyOperation operation = new() + { + TableName = "sensor_data", + Schema = "public" + }; + + // Act + generator.Generate("migrationBuilder", [operation], builder); + + // Assert + string result = builder.ToString(); + Assert.Contains("remove_retention_policy", result); + Assert.Contains("if_exists", result); + Assert.DoesNotContain("migrationBuilder;", result); + } + + #endregion + #region Helper Methods private static CSharpMigrationOperationGeneratorDependencies CreateDependencies() diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs index 54cea23..ab2b278 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateIntegrationTests.cs @@ -11,10 +11,9 @@ public class ContinuousAggregateIntegrationTests : MigrationTestBase, IAsyncLife private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -24,7 +23,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -104,14 +103,14 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_basic', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_basic', NULL, NULL);", [], TestContext.Current.CancellationToken); List aggregates = await context.TradeAggregates .OrderBy(a => a.TimeBucket) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); BasicAggregatesAggregate firstAggregate = aggregates.First(); @@ -186,12 +185,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {105.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {103.00m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {103.00m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_first_last', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_first_last', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(aggregates); Assert.Equal(100.00m, aggregates[0].FirstPrice); @@ -263,14 +262,14 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {110.00m}, {200}, {"NASDAQ"}), - ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {105.00m}, {150}, {"LSE"})"); + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {105.00m}, {150}, {"LSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_grouped', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_grouped', NULL, NULL);", [], TestContext.Current.CancellationToken); List aggregates = await context.TradeAggregates .OrderBy(a => a.Exchange) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(3, aggregates.Count); Assert.Equal("LSE", aggregates[0].Exchange); @@ -344,12 +343,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"TSLA"}, {200.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"MSFT"}, {300.00m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"MSFT"}, {300.00m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_filtered', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_filtered', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(aggregates); Assert.Equal(100.00m, aggregates[0].AvgPrice); @@ -416,16 +415,16 @@ public async Task Should_Create_ContinuousAggregate_WithNoData_Option() await context.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})", TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Empty(aggregates); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_no_data', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_no_data', NULL, NULL);", [], TestContext.Current.CancellationToken); - aggregates = await context.TradeAggregates.ToListAsync(); + aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(aggregates); } @@ -492,12 +491,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_custom_chunk', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_custom_chunk', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); } @@ -568,12 +567,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_with_indexes', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_with_indexes', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); } @@ -638,9 +637,9 @@ public async Task Should_Create_ContinuousAggregate_With_MaterializedOnly_False( await context.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") - VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})", TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(aggregates); Assert.Equal(100.00m, aggregates[0].AvgPrice); @@ -735,19 +734,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Alter_ContinuousAggregate_ChunkInterval() { await using AlterChunkIntervalInitialContext context1 = new(_connectionString!); - await context1.Database.EnsureCreatedAsync(); + await context1.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); await context1.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""Trades"" (""Timestamp"", ""Ticker"", ""Price"", ""Size"", ""Exchange"") VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context1.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_alterable', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_alterable', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregatesBefore = await context1.TradeAggregates.ToListAsync(); + List aggregatesBefore = await context1.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregatesBefore); await using AlterChunkIntervalModifiedContext context2 = new(_connectionString!); @@ -755,9 +754,9 @@ await context1.Database.ExecuteSqlRawAsync( await context2.Database.ExecuteSqlRawAsync(@" ALTER MATERIALIZED VIEW trade_aggregate_alterable SET (timescaledb.chunk_interval = '14 days'); - "); + ", [], TestContext.Current.CancellationToken); - List aggregatesAfter = await context2.TradeAggregates.ToListAsync(); + List aggregatesAfter = await context2.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregatesAfter); Assert.Equal(aggregatesBefore.Count, aggregatesAfter.Count); } @@ -825,17 +824,17 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_materialized_only', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_materialized_only', NULL, NULL);", [], TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync(@" ALTER MATERIALIZED VIEW trade_aggregate_materialized_only SET (timescaledb.materialized_only = true); - "); + ", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); } @@ -905,7 +904,7 @@ public async Task Should_Alter_ContinuousAggregate_CreateGroupIndexes() await context.Database.ExecuteSqlRawAsync(@" ALTER MATERIALIZED VIEW trade_aggregate_group_indexes SET (timescaledb.create_group_indexes = true); - "); + ", [], TestContext.Current.CancellationToken); }); } @@ -971,20 +970,20 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_to_drop', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_to_drop', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregatesBefore = await context.TradeAggregates.ToListAsync(); + List aggregatesBefore = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregatesBefore); await context.Database.ExecuteSqlRawAsync( - "DROP MATERIALIZED VIEW IF EXISTS trade_aggregate_to_drop;"); + "DROP MATERIALIZED VIEW IF EXISTS trade_aggregate_to_drop;", [], TestContext.Current.CancellationToken); await Assert.ThrowsAsync(async () => { - await context.TradeAggregates.ToListAsync(); + await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); }); } @@ -1050,12 +1049,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {150.50m}, {100}, {"NYSE"}), ({new DateTime(2025, 1, 6, 10, 30, 0, DateTimeKind.Utc)}, {"AAPL"}, {151.00m}, {200}, {"NYSE"}), - ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})"); + ({new DateTime(2025, 1, 6, 10, 45, 0, DateTimeKind.Utc)}, {"AAPL"}, {149.75m}, {150}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.trade_aggregate_sql_gen', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.trade_aggregate_sql_gen', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); SqlGenerationAggregate firstAggregate = aggregates.First(); @@ -1122,12 +1121,12 @@ public async Task Should_Handle_SnakeCase_Naming_Convention() await context.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO trades (timestamp, ticker, price, size, exchange) - VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})"); + VALUES ({new DateTime(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc)}, {"AAPL"}, {100.00m}, {100}, {"NYSE"})", TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.snake_case_test_aggregate', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.snake_case_test_aggregate', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.TradeAggregates.ToListAsync(); + List aggregates = await context.TradeAggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(aggregates); Assert.Equal(100.00m, aggregates[0].AvgPrice); diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs index a6ddd87..5a7f6fc 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyIntegrationTests.cs @@ -14,10 +14,9 @@ public class ContinuousAggregatePolicyIntegrationTests : MigrationTestBase, IAsy private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -27,7 +26,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -92,7 +91,7 @@ public async Task Should_Create_Policy_With_FluentApi() await CreateDatabaseViaMigrationAsync(context); // Assert - Verify the continuous aggregate view exists - List aggregates = await context.Aggregates.ToListAsync(); + List aggregates = await context.Aggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotNull(aggregates); } @@ -526,13 +525,13 @@ public async Task Should_Execute_Migration_Successfully() await context.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""Metrics"" (""Timestamp"", ""Value"") VALUES ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {100.5}) - "); + ", TestContext.Current.CancellationToken); // Manually refresh the continuous aggregate await context.Database.ExecuteSqlRawAsync( - "CALL refresh_continuous_aggregate('public.hourly_metrics', NULL, NULL);"); + "CALL refresh_continuous_aggregate('public.hourly_metrics', NULL, NULL);", [], TestContext.Current.CancellationToken); - List aggregates = await context.Aggregates.ToListAsync(); + List aggregates = await context.Aggregates.ToListAsync(TestContext.Current.CancellationToken); Assert.NotEmpty(aggregates); } @@ -690,7 +689,7 @@ public async Task Should_Work_With_Custom_Schema() await using CustomSchemaContext9 context = new(_connectionString!); // Create the schema first - await context.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS analytics;"); + await context.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS analytics;", [], TestContext.Current.CancellationToken); // Act await CreateDatabaseViaMigrationAsync(context); diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs index 63f3482..3eb253e 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregatePolicyScaffoldingExtractorTests.cs @@ -14,10 +14,9 @@ public class ContinuousAggregatePolicyScaffoldingExtractorTests : MigrationTestB private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -27,7 +26,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { diff --git a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs index a3af55d..88590f9 100644 --- a/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ContinuousAggregateScaffoldingExtractorTests.cs @@ -13,10 +13,9 @@ public class ContinuousAggregateScaffoldingExtractorTests : MigrationTestBase, I private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -26,7 +25,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { diff --git a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs index 5e8a3cd..facb53b 100644 --- a/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableIntegrationTests.cs @@ -20,10 +20,9 @@ private class CompressionSettingInfo public bool IsNullsFirst { get; set; } } - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -33,7 +32,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -320,12 +319,12 @@ public async Task Should_Create_Minimal_Hypertable() DateTime timestamp = new(2025, 1, 6, 10, 0, 0, DateTimeKind.Utc); double value = 100.5; await context.Database.ExecuteSqlInterpolatedAsync( - $"INSERT INTO \"Metrics\" (\"Timestamp\", \"Value\") VALUES ({timestamp}, {value})"); + $"INSERT INTO \"Metrics\" (\"Timestamp\", \"Value\") VALUES ({timestamp}, {value})", TestContext.Current.CancellationToken); bool isHypertable = await IsHypertableAsync(context, "Metrics"); Assert.True(isHypertable); - List metrics = await context.Metrics.ToListAsync(); + List metrics = await context.Metrics.ToListAsync(TestContext.Current.CancellationToken); Assert.Single(metrics); Assert.Equal(100.5, metrics[0].Value); } @@ -926,12 +925,12 @@ await context.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}, {45.0}), ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {21.0}, {46.0}), - ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {19.5}, {50.0})"); + ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {19.5}, {50.0})", TestContext.Current.CancellationToken); - List data = await context.IoTData.ToListAsync(); + List data = await context.IoTData.ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(3, data.Count); - List device1Data = await context.IoTData.Where(d => d.DeviceId == "device_1").ToListAsync(); + List device1Data = await context.IoTData.Where(d => d.DeviceId == "device_1").ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(2, device1Data.Count); int chunkCount = await GetChunkCountAsync(context, "IoTData"); @@ -985,14 +984,14 @@ public async Task Should_Handle_LargeDataset() string sql = $@"INSERT INTO ""PerformanceTest"" (""Timestamp"", ""SensorId"", ""Value"") VALUES {string.Join(", ", valueRows)}"; - await context.Database.ExecuteSqlRawAsync(sql); + await context.Database.ExecuteSqlRawAsync(sql, [], TestContext.Current.CancellationToken); - int count = await context.PerformanceTest.CountAsync(); + int count = await context.PerformanceTest.CountAsync(TestContext.Current.CancellationToken); Assert.Equal(100, count); List sensor0Data = await context.PerformanceTest .Where(d => d.SensorId == 0) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(10, sensor0Data.Count); int chunkCount = await GetChunkCountAsync(context, "PerformanceTest"); diff --git a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs index b508a40..6d9b174 100644 --- a/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableMigrateDataIntegrationTests.cs @@ -14,10 +14,9 @@ public class HypertableMigrateDataIntegrationTests : MigrationTestBase, IAsyncLi private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -27,7 +26,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -307,7 +306,7 @@ await initialContext.Database.ExecuteSqlInterpolatedAsync($@" VALUES ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5}), ({new DateTime(2025, 1, 1, 11, 0, 0, DateTimeKind.Utc)}, {"device_2"}, {21.0}), - ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_3"}, {19.5})"); + ({new DateTime(2025, 1, 2, 10, 0, 0, DateTimeKind.Utc)}, {"device_3"}, {19.5})", TestContext.Current.CancellationToken); // Verify data exists before conversion int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataMigration"); @@ -326,7 +325,7 @@ await initialContext.Database.ExecuteSqlInterpolatedAsync($@" Assert.Equal(3, countAfterConversion); // Assert - Verify data can still be queried via EF Core - List data = await convertedContext.SensorData.ToListAsync(); + List data = await convertedContext.SensorData.ToListAsync(TestContext.Current.CancellationToken); Assert.Equal(3, data.Count); Assert.Contains(data, d => d.DeviceId == "device_1" && d.Temperature == 20.5); Assert.Contains(data, d => d.DeviceId == "device_2" && d.Temperature == 21.0); @@ -392,7 +391,7 @@ public async Task Should_Apply_MigrateData_False_When_Converting_To_Hypertable() await initialContext.Database.ExecuteSqlInterpolatedAsync($@" INSERT INTO ""SensorDataNoMigration"" (""Timestamp"", ""DeviceId"", ""Temperature"") VALUES - ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5})"); + ({new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc)}, {"device_1"}, {20.5})", TestContext.Current.CancellationToken); // Verify data exists before conversion int countBeforeConversion = await GetRowCountAsync(initialContext, "SensorDataNoMigration"); diff --git a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs index 45c47a1..b2ffa10 100644 --- a/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/HypertableScaffoldingExtractorTests.cs @@ -12,10 +12,9 @@ public class HypertableScaffoldingExtractorTests : MigrationTestBase, IAsyncLife private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -25,7 +24,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -374,9 +373,11 @@ public async Task Should_Extract_ChunkSkipColumns() await CreateDatabaseViaMigrationAsync(context); await context.Database.ExecuteSqlRawAsync( - "INSERT INTO \"Metrics\" (\"Timestamp\", \"DeviceId\", \"Location\", \"Value\", \"SensorId\") VALUES (NOW(), 'device1', 'location1', 100.0, 1)"); + "INSERT INTO \"Metrics\" (\"Timestamp\", \"DeviceId\", \"Location\", \"Value\", \"SensorId\") VALUES (NOW(), 'device1', 'location1', 100.0, 1)", + TestContext.Current.CancellationToken); await context.Database.ExecuteSqlRawAsync( - "SELECT compress_chunk(i) FROM show_chunks('\"Metrics\"') AS i;"); + "SELECT compress_chunk(i) FROM show_chunks('\"Metrics\"') AS i;", + TestContext.Current.CancellationToken); HypertableScaffoldingExtractor extractor = new(); await using NpgsqlConnection connection = new(_connectionString); diff --git a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs index 8ce9986..b56a379 100644 --- a/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationLifecycleTests.cs @@ -15,10 +15,9 @@ public class MigrationLifecycleTests : MigrationTestBase, IAsyncLifetime private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -28,7 +27,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { diff --git a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs index b1dbe96..6405a4c 100644 --- a/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs +++ b/tests/Eftdb.Tests/Integration/MigrationOperationOrderingTests.cs @@ -1,5 +1,6 @@ using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -726,6 +727,133 @@ public void Should_Order_Hypertable_And_Indexes_Correctly() #endregion + #region Should_Order_CreateHypertable_Before_AddRetentionPolicy + + private class OrderingMetric9 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderingContext9 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics9"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + /// + /// Verifies that CreateHypertableOperation appears before AddRetentionPolicyOperation. + /// Retention policies require the hypertable to exist first. + /// + [Fact] + public void Should_Order_CreateHypertable_Before_AddRetentionPolicy() + { + // Arrange + using OrderingContext9 context = new(); + + // Act + IReadOnlyList operations = GenerateMigrationOperations(null, context); + + // Assert + int createTableIndex = operations.ToList().FindIndex(op => + op is CreateTableOperation createTable && createTable.Name == "OrderingMetrics9"); + int hypertableIndex = operations.ToList().FindIndex(op => + op is CreateHypertableOperation hypertable && hypertable.TableName == "OrderingMetrics9"); + int retentionPolicyIndex = operations.ToList().FindIndex(op => + op is AddRetentionPolicyOperation policy && policy.TableName == "OrderingMetrics9"); + + Assert.NotEqual(-1, createTableIndex); + Assert.NotEqual(-1, hypertableIndex); + Assert.NotEqual(-1, retentionPolicyIndex); + + Assert.True(createTableIndex < hypertableIndex, + $"CreateTable (index {createTableIndex}) should appear before CreateHypertable (index {hypertableIndex})"); + Assert.True(hypertableIndex < retentionPolicyIndex, + $"CreateHypertable (index {hypertableIndex}) should appear before AddRetentionPolicy (index {retentionPolicyIndex})"); + } + + #endregion + + #region Should_Order_DropRetentionPolicy_Before_DropTable + + private class OrderingMetric10 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OrderingSourceContext10 : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("OrderingMetrics10"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp) + .WithChunkTimeInterval("1 day"); + entity.WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + private class OrderingTargetContext10 : DbContext + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + } + + /// + /// Verifies that DropRetentionPolicyOperation appears before DropTableOperation. + /// The retention policy must be removed before the hypertable is dropped. + /// + [Fact] + public void Should_Order_DropRetentionPolicy_Before_DropTable() + { + // Arrange + using OrderingSourceContext10 sourceContext = new(); + using OrderingTargetContext10 targetContext = new(); + + // Act — diff from source (has table + retention policy) to target (empty) + IReadOnlyList operations = GenerateMigrationOperations(sourceContext, targetContext); + + // Assert + List opsList = [.. operations]; + + int dropRetentionPolicyIndex = opsList.FindIndex(op => op is DropRetentionPolicyOperation); + int dropTableIndex = opsList.FindIndex(op => + op is DropTableOperation dropTable && dropTable.Name == "OrderingMetrics10"); + + Assert.NotEqual(-1, dropRetentionPolicyIndex); + Assert.NotEqual(-1, dropTableIndex); + Assert.True(dropRetentionPolicyIndex < dropTableIndex, + $"DropRetentionPolicy (index {dropRetentionPolicyIndex}) should appear before DropTable (index {dropTableIndex})"); + } + + #endregion + #region Should_Fail_With_Unstable_Sort_Many_Tables // Many entity classes to generate enough operations to trigger unstable sort behavior diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs index 2cd3d8c..323a11f 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyIntegrationTests.cs @@ -15,10 +15,9 @@ public class ReorderPolicyIntegrationTests : IAsyncLifetime private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -28,7 +27,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -239,7 +238,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Create_ReorderPolicy_WithMinimalConfig() { await using MinimalConfigContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_minimal_config"); Assert.True(hasPolicy); @@ -292,7 +291,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Create_ReorderPolicy_WithAllOptions() { await using AllOptionsContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_all_options"); Assert.True(hasPolicy); @@ -350,7 +349,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Create_ReorderPolicy_WithCustomScheduleInterval() { await using CustomScheduleContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_custom_schedule"); TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); @@ -421,7 +420,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Alter_ReorderPolicy_ScheduleInterval() { await using InitialScheduleIntervalContext initialContext = new(_connectionString!); - await initialContext.Database.EnsureCreatedAsync(); + await initialContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_schedule_interval"); TimeSpan initialSchedule = await GetScheduleIntervalAsync(initialContext, jobId); @@ -440,7 +439,7 @@ public async Task Should_Alter_ReorderPolicy_ScheduleInterval() foreach (MigrationCommand command in commands) { - await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText, TestContext.Current.CancellationToken); } TimeSpan newSchedule = await GetScheduleIntervalAsync(modifiedContext, jobId); @@ -510,7 +509,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Alter_ReorderPolicy_MaxRuntime() { await using InitialMaxRuntimeContext initialContext = new(_connectionString!); - await initialContext.Database.EnsureCreatedAsync(); + await initialContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_max_runtime"); @@ -527,7 +526,7 @@ public async Task Should_Alter_ReorderPolicy_MaxRuntime() foreach (MigrationCommand command in commands) { - await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText, TestContext.Current.CancellationToken); } TimeSpan maxRuntime = await GetMaxRuntimeAsync(modifiedContext, jobId); @@ -597,7 +596,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Alter_ReorderPolicy_MaxRetries() { await using InitialMaxRetriesContext initialContext = new(_connectionString!); - await initialContext.Database.EnsureCreatedAsync(); + await initialContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_max_retries"); @@ -614,7 +613,7 @@ public async Task Should_Alter_ReorderPolicy_MaxRetries() foreach (MigrationCommand command in commands) { - await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText, TestContext.Current.CancellationToken); } int maxRetries = await GetMaxRetriesAsync(modifiedContext, jobId); @@ -688,7 +687,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Alter_ReorderPolicy_MultipleParameters() { await using InitialMultipleParamsContext initialContext = new(_connectionString!); - await initialContext.Database.EnsureCreatedAsync(); + await initialContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(initialContext, "metrics_multiple_params"); @@ -705,7 +704,7 @@ public async Task Should_Alter_ReorderPolicy_MultipleParameters() foreach (MigrationCommand command in commands) { - await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText); + await modifiedContext.Database.ExecuteSqlRawAsync(command.CommandText, TestContext.Current.CancellationToken); } TimeSpan scheduleInterval = await GetScheduleIntervalAsync(modifiedContext, jobId); @@ -774,7 +773,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Drop_ReorderPolicy() { await using DropPolicyInitialContext initialContext = new(_connectionString!); - await initialContext.Database.EnsureCreatedAsync(); + await initialContext.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); IRelationalModel sourceRelationalModel = initialContext.GetService().Model.GetRelationalModel(); @@ -793,7 +792,7 @@ public async Task Should_Drop_ReorderPolicy() foreach (MigrationCommand command in commands) { - await removedContext.Database.ExecuteSqlRawAsync(command.CommandText); + await removedContext.Database.ExecuteSqlRawAsync(command.CommandText, TestContext.Current.CancellationToken); } hasPolicy = await HasReorderPolicyAsync(removedContext, "metrics_drop_policy"); @@ -836,7 +835,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Create_ReorderPolicy_OnExistingHypertable() { await using ExistingHypertableContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); bool hasPolicy = await HasReorderPolicyAsync(context, "metrics_existing_hypertable"); Assert.True(hasPolicy); @@ -886,10 +885,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Query_TimescaleDB_Jobs_View() { await using JobsViewContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); await using NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); - await connection.OpenAsync(); + await connection.OpenAsync(TestContext.Current.CancellationToken); await using NpgsqlCommand command = connection.CreateCommand(); command.CommandText = @" SELECT application_name, hypertable_name, schedule_interval, max_runtime, max_retries, retry_period @@ -898,8 +897,8 @@ WHERE application_name LIKE 'Reorder Policy%' AND hypertable_name = 'metrics_jobs_view'; "; - await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(); - Assert.True(await reader.ReadAsync()); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); + Assert.True(await reader.ReadAsync(TestContext.Current.CancellationToken)); string applicationName = reader.GetString(0); string hypertableName = reader.GetString(1); @@ -955,7 +954,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Handle_UnlimitedRetries() { await using UnlimitedRetriesContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_unlimited_retries"); int maxRetries = await GetMaxRetriesAsync(context, jobId); @@ -1002,7 +1001,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public async Task Should_Handle_ZeroMaxRuntime() { await using ZeroMaxRuntimeContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); int jobId = await GetReorderPolicyJobIdAsync(context, "metrics_zero_max_runtime"); TimeSpan maxRuntime = await GetMaxRuntimeAsync(context, jobId); diff --git a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs index 8ce4458..59843bd 100644 --- a/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs +++ b/tests/Eftdb.Tests/Integration/ReorderPolicyScaffoldingExtractorTests.cs @@ -12,10 +12,9 @@ public class ReorderPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyncL private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -25,7 +24,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs new file mode 100644 index 0000000..6ff6677 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyIntegrationTests.cs @@ -0,0 +1,778 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class RetentionPolicyIntegrationTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Helper Methods + + private static async Task HasRetentionPolicyAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT COUNT(*) > 0 + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' + AND hypertable_name = @tableName; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is bool boolResult && boolResult; + } + + private static async Task GetRetentionPolicyJobIdAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT job_id + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' + AND hypertable_name = @tableName + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is int jobId ? jobId : 0; + } + + private static async Task GetScheduleIntervalAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT schedule_interval + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is TimeSpan interval ? interval : TimeSpan.Zero; + } + + private static async Task GetRetentionPolicyConfigAsync(DbContext context, string tableName) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT config::text + FROM timescaledb_information.jobs + WHERE proc_name = 'policy_retention' + AND hypertable_name = @tableName + LIMIT 1; + "; + command.Parameters.AddWithValue("tableName", tableName); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result as string; + } + + private static async Task GetJobInitialStartAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT initial_start + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is DateTime dt ? dt : null; + } + + private static async Task GetJobMaxRetriesAsync(DbContext context, int jobId) + { + NpgsqlConnection connection = (NpgsqlConnection)context.Database.GetDbConnection(); + bool wasOpen = connection.State == System.Data.ConnectionState.Open; + + if (!wasOpen) + { + await connection.OpenAsync(); + } + + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = @" + SELECT max_retries + FROM timescaledb_information.jobs + WHERE job_id = @jobId; + "; + command.Parameters.AddWithValue("jobId", jobId); + object? result = await command.ExecuteScalarAsync(); + + if (!wasOpen) + { + await connection.CloseAsync(); + } + + return result is int maxRetries ? maxRetries : -1; + } + + #endregion + + #region Should_Create_RetentionPolicy_WithDropAfter + + private class DropAfterMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DropAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_drop_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_WithDropAfter() + { + await using DropAfterContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasRetentionPolicyAsync(context, "retention_drop_after"); + Assert.True(hasPolicy); + + int jobId = await GetRetentionPolicyJobIdAsync(context, "retention_drop_after"); + Assert.True(jobId > 0); + + string? config = await GetRetentionPolicyConfigAsync(context, "retention_drop_after"); + Assert.NotNull(config); + Assert.Contains("drop_after", config); + } + + #endregion + + #region Should_Create_RetentionPolicy_WithDropCreatedBefore + + private class DropCreatedBeforeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_drop_created_before"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_WithDropCreatedBefore() + { + await using DropCreatedBeforeContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasRetentionPolicyAsync(context, "retention_drop_created_before"); + Assert.True(hasPolicy); + + string? config = await GetRetentionPolicyConfigAsync(context, "retention_drop_created_before"); + Assert.NotNull(config); + Assert.Contains("drop_created_before", config); + } + + #endregion + + #region Should_Alter_RetentionPolicy_ScheduleInterval + + private class AlterScheduleMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "1 day" + ); + }); + } + } + + private class ModifiedAlterScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "12 hours" // <-- Changed from "1 day" + ); + }); + } + } + + [Fact] + public async Task Should_Alter_RetentionPolicy_ScheduleInterval() + { + await using InitialAlterScheduleContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + int jobId = await GetRetentionPolicyJobIdAsync(initialContext, "retention_alter_schedule"); + TimeSpan initialSchedule = await GetScheduleIntervalAsync(initialContext, jobId); + Assert.Equal(TimeSpan.FromDays(1), initialSchedule); + + await using ModifiedAlterScheduleContext modifiedContext = new(_connectionString!); + + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + TimeSpan newSchedule = await GetScheduleIntervalAsync(modifiedContext, jobId); + Assert.Equal(TimeSpan.FromHours(12), newSchedule); + } + + #endregion + + #region Should_Alter_RetentionPolicy_DropAfter + + private class AlterDropAfterMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterDropAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_drop_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class ModifiedAlterDropAfterContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_drop_after"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "30 days"); // <-- Changed from "7 days" + }); + } + } + + [Fact] + public async Task Should_Alter_RetentionPolicy_DropAfter() + { + await using InitialAlterDropAfterContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + int initialJobId = await GetRetentionPolicyJobIdAsync(initialContext, "retention_alter_drop_after"); + Assert.True(initialJobId > 0); + + await using ModifiedAlterDropAfterContext modifiedContext = new(_connectionString!); + + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + bool hasPolicy = await HasRetentionPolicyAsync(modifiedContext, "retention_alter_drop_after"); + Assert.True(hasPolicy); + + string? config = await GetRetentionPolicyConfigAsync(modifiedContext, "retention_alter_drop_after"); + Assert.NotNull(config); + Assert.Contains("drop_after", config); + } + + #endregion + + #region Should_Drop_RetentionPolicy + + private class DropPolicyMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class DropPolicyInitialContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class DropPolicyRemovedContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_drop_policy"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + // <-- Retention policy removed + }); + } + } + + [Fact] + public async Task Should_Drop_RetentionPolicy() + { + await using DropPolicyInitialContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + bool hasPolicy = await HasRetentionPolicyAsync(initialContext, "retention_drop_policy"); + Assert.True(hasPolicy); + + await using DropPolicyRemovedContext removedContext = new(_connectionString!); + + await AlterDatabaseViaMigrationAsync(initialContext, removedContext); + + hasPolicy = await HasRetentionPolicyAsync(removedContext, "retention_drop_policy"); + Assert.False(hasPolicy); + } + + #endregion + + #region Should_Create_RetentionPolicy_WithCustomScheduleInterval + + private class CustomScheduleMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class CustomScheduleContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_custom_schedule"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy( + dropAfter: "14 days", + scheduleInterval: "6 hours" + ); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_WithCustomScheduleInterval() + { + await using CustomScheduleContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + int jobId = await GetRetentionPolicyJobIdAsync(context, "retention_custom_schedule"); + Assert.True(jobId > 0); + + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); + Assert.Equal(TimeSpan.FromHours(6), scheduleInterval); + } + + #endregion + + #region Should_Alter_RetentionPolicy_DropAfter_To_DropCreatedBefore + + private class AlterDropAfterToDropCreatedBeforeMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialAlterDropAfterToDropCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_da_to_dcb"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + private class ModifiedAlterDropAfterToDropCreatedBeforeContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_alter_da_to_dcb"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); // <-- Changed from dropAfter: "7 days" + }); + } + } + + //[Fact(Skip = "TimescaleDB bug #9446: alter_job fails when config contains drop_created_before instead of drop_after. " + + // "The generator's Alter recreation path emits alter_job to reapply job settings, which hits this bug.")] + [Fact] + public async Task Should_Alter_RetentionPolicy_DropAfter_To_DropCreatedBefore() + { + await using InitialAlterDropAfterToDropCreatedBeforeContext initialContext = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(initialContext); + + bool hasPolicy = await HasRetentionPolicyAsync(initialContext, "retention_alter_da_to_dcb"); + Assert.True(hasPolicy); + + string? initialConfig = await GetRetentionPolicyConfigAsync(initialContext, "retention_alter_da_to_dcb"); + Assert.NotNull(initialConfig); + Assert.Contains("drop_after", initialConfig); + + await using ModifiedAlterDropAfterToDropCreatedBeforeContext modifiedContext = new(_connectionString!); + + await AlterDatabaseViaMigrationAsync(initialContext, modifiedContext); + + hasPolicy = await HasRetentionPolicyAsync(modifiedContext, "retention_alter_da_to_dcb"); + Assert.True(hasPolicy); + + string? modifiedConfig = await GetRetentionPolicyConfigAsync(modifiedContext, "retention_alter_da_to_dcb"); + Assert.NotNull(modifiedConfig); + Assert.Contains("drop_created_before", modifiedConfig); + Assert.DoesNotContain("drop_after", modifiedConfig); + } + + #endregion + + #region Should_Create_RetentionPolicy_WithInitialStart + + private class InitialStartMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_initial_start"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_WithInitialStart() + { + await using InitialStartContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasRetentionPolicyAsync(context, "retention_initial_start"); + Assert.True(hasPolicy); + + int jobId = await GetRetentionPolicyJobIdAsync(context, "retention_initial_start"); + Assert.True(jobId > 0); + + DateTime? initialStart = await GetJobInitialStartAsync(context, jobId); + Assert.NotNull(initialStart); + } + + #endregion + + #region Should_Create_RetentionPolicy_WithFullJobSettings + + private class FullJobSettingsMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class FullJobSettingsContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_full_job_settings"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy( + dropAfter: "14 days", + scheduleInterval: "6 hours", + maxRuntime: "1 hour", + maxRetries: 3, + retryPeriod: "10 minutes" + ); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_WithFullJobSettings() + { + await using FullJobSettingsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + bool hasPolicy = await HasRetentionPolicyAsync(context, "retention_full_job_settings"); + Assert.True(hasPolicy); + + int jobId = await GetRetentionPolicyJobIdAsync(context, "retention_full_job_settings"); + Assert.True(jobId > 0); + + TimeSpan scheduleInterval = await GetScheduleIntervalAsync(context, jobId); + Assert.Equal(TimeSpan.FromHours(6), scheduleInterval); + + int maxRetries = await GetJobMaxRetriesAsync(context, jobId); + Assert.Equal(3, maxRetries); + } + + #endregion + + #region Should_Create_RetentionPolicy_ViaEnsureCreated + + private class EnsureCreatedMetric + { + public int Id { get; set; } + public DateTime Time { get; set; } + public double Value { get; set; } + } + + private class EnsureCreatedContext(string connectionString) : DbContext + { + public DbSet Metrics { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("retention_ensure_created"); + entity.HasKey(e => new { e.Time, e.Id }); + entity.IsHypertable(e => e.Time); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Create_RetentionPolicy_ViaEnsureCreated() + { + await using EnsureCreatedContext context = new(_connectionString!); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + + bool hasPolicy = await HasRetentionPolicyAsync(context, "retention_ensure_created"); + Assert.True(hasPolicy); + + int jobId = await GetRetentionPolicyJobIdAsync(context, "retention_ensure_created"); + Assert.True(jobId > 0); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs new file mode 100644 index 0000000..adbd820 --- /dev/null +++ b/tests/Eftdb.Tests/Integration/RetentionPolicyScaffoldingExtractorTests.cs @@ -0,0 +1,487 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Integration; + +public class RetentionPolicyScaffoldingExtractorTests : MigrationTestBase, IAsyncLifetime +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + public async ValueTask InitializeAsync() + { + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") + .WithDatabase("test_db") + .WithUsername("test_user") + .WithPassword("test_password") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + public async ValueTask DisposeAsync() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + } + + #region Should_Extract_Minimal_RetentionPolicy_With_DropAfter + + private class DropAfterMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropAfterContext(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.HasNoKey(); + entity.ToTable("scaff_retention_drop_after"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_Minimal_RetentionPolicy_With_DropAfter() + { + await using DropAfterContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_retention_drop_after"))); + + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_drop_after")]; + Assert.Equal("7 days", info.DropAfter); + Assert.Null(info.DropCreatedBefore); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_DropCreatedBefore + + private class DropCreatedBeforeMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeContext(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.HasNoKey(); + entity.ToTable("scaff_retention_dcb"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropCreatedBefore: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_RetentionPolicy_With_DropCreatedBefore() + { + await using DropCreatedBeforeContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_retention_dcb"))); + + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_dcb")]; + Assert.Null(info.DropAfter); + Assert.Equal("30 days", info.DropCreatedBefore); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_All_Job_Settings + + private class AllSettingsMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class AllSettingsContext(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.HasNoKey(); + entity.ToTable("scaff_retention_all_settings"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "14 days", + scheduleInterval: "1 day", + maxRuntime: "02:00:00", + maxRetries: 5, + retryPeriod: "00:15:00"); + }); + } + } + + [Fact] + public async Task Should_Extract_RetentionPolicy_With_All_Job_Settings() + { + await using AllSettingsContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_all_settings")]; + + Assert.Equal("14 days", info.DropAfter); + Assert.Null(info.DropCreatedBefore); + Assert.Equal("1 day", info.ScheduleInterval); + Assert.Equal("02:00:00", info.MaxRuntime); + Assert.Equal(5, info.MaxRetries); + Assert.Equal("00:15:00", info.RetryPeriod); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_InitialStart + + private class InitialStartMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext(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.HasNoKey(); + entity.ToTable("scaff_retention_initial_start"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + }); + } + } + + [Fact] + public async Task Should_Extract_RetentionPolicy_With_InitialStart() + { + await using InitialStartContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_initial_start")]; + + Assert.NotNull(info.InitialStart); + DateTime expectedDate = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(expectedDate, info.InitialStart.Value); + } + + #endregion + + #region Should_Extract_Multiple_RetentionPolicies + + private class MultiPolicyMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiPolicyMetric2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MultiPolicyContext(string connectionString) : DbContext + { + public DbSet Metrics1 => Set(); + public DbSet Metrics2 => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_retention_multi_1"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_retention_multi_2"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "30 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_Multiple_RetentionPolicies() + { + await using MultiPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Equal(2, result.Count); + Assert.True(result.ContainsKey(("public", "scaff_retention_multi_1"))); + Assert.True(result.ContainsKey(("public", "scaff_retention_multi_2"))); + + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info1 = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_multi_1")]; + Assert.Equal("7 days", info1.DropAfter); + + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info2 = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("public", "scaff_retention_multi_2")]; + Assert.Equal("30 days", info2.DropAfter); + } + + #endregion + + #region Should_Return_Empty_When_No_Policies + + private class NoPolicyMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoPolicyContext(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.HasNoKey(); + entity.ToTable("scaff_retention_none"); + entity.IsHypertable(x => x.Timestamp); + }); + } + } + + [Fact] + public async Task Should_Return_Empty_When_No_Policies() + { + await using NoPolicyContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Empty(result); + } + + #endregion + + #region Should_Handle_Connection_Already_Open + + private class OpenConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class OpenConnectionContext(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.HasNoKey(); + entity.ToTable("scaff_retention_open_conn"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Handle_Connection_Already_Open() + { + await using OpenConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_retention_open_conn"))); + Assert.Equal(System.Data.ConnectionState.Open, connection.State); + } + + #endregion + + #region Should_Handle_Connection_Closed + + private class ClosedConnectionMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ClosedConnectionContext(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.HasNoKey(); + entity.ToTable("scaff_retention_closed_conn"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Handle_Connection_Closed() + { + await using ClosedConnectionContext context = new(_connectionString!); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + + Assert.Equal(System.Data.ConnectionState.Closed, connection.State); + + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("public", "scaff_retention_closed_conn"))); + Assert.Equal(System.Data.ConnectionState.Closed, connection.State); + } + + #endregion + + #region Should_Extract_RetentionPolicy_With_Custom_Schema + + private class CustomSchemaMetric + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CustomSchemaContext(string connectionString) : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(connectionString).UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("analytics"); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("scaff_retention_custom_schema"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public async Task Should_Extract_RetentionPolicy_With_Custom_Schema() + { + await using CustomSchemaContext context = new(_connectionString!); + await context.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS analytics;", [], TestContext.Current.CancellationToken); + await CreateDatabaseViaMigrationAsync(context); + + RetentionPolicyScaffoldingExtractor extractor = new(); + await using NpgsqlConnection connection = new(_connectionString); + Dictionary<(string Schema, string TableName), object> result = extractor.Extract(connection); + + Assert.Single(result); + Assert.True(result.ContainsKey(("analytics", "scaff_retention_custom_schema"))); + + RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo info = + (RetentionPolicyScaffoldingExtractor.RetentionPolicyInfo)result[("analytics", "scaff_retention_custom_schema")]; + Assert.Equal("7 days", info.DropAfter); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs index e182791..044540d 100644 --- a/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs +++ b/tests/Eftdb.Tests/Integration/TimeBucketIntegrationTests.cs @@ -10,10 +10,9 @@ public class TimeBucketIntegrationTests : MigrationTestBase, IAsyncLifetime private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -23,7 +22,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { @@ -69,14 +68,15 @@ public async Task Should_Translate_TimeBucket_In_Select() { DateTime ts = baseTime.AddMinutes(i); await context.Database.ExecuteSqlInterpolatedAsync( - $"INSERT INTO \"tb_select_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i * 10)})"); + $"INSERT INTO \"tb_select_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i * 10)})", + TestContext.Current.CancellationToken); } // Act List buckets = await context.Metrics .Select(m => EF.Functions.TimeBucket(TimeSpan.FromMinutes(5), m.Timestamp)) .Distinct() - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); // Assert Assert.Single(buckets); @@ -123,7 +123,8 @@ public async Task Should_Translate_TimeBucket_In_GroupBy() { DateTime ts = baseTime.AddMinutes(i); await context.Database.ExecuteSqlInterpolatedAsync( - $"INSERT INTO \"tb_groupby_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i + 1)})"); + $"INSERT INTO \"tb_groupby_metrics\" (\"Timestamp\", \"Value\") VALUES ({ts}, {(double)(i + 1)})", + TestContext.Current.CancellationToken); } // Act — group into 5-minute buckets and sum @@ -136,7 +137,7 @@ await context.Database.ExecuteSqlInterpolatedAsync( Count = g.Count() }) .OrderBy(r => r.Bucket) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); // Assert — expect 3 buckets: [10:00-10:05), [10:05-10:10), [10:10-10:15) Assert.Equal(3, results.Count); @@ -187,12 +188,13 @@ public async Task Should_Translate_TimeBucket_With_Integer_Arguments() { // Arrange await using TimeBucketIntContext context = new(_connectionString!); - await context.Database.EnsureCreatedAsync(); + await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); 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)})"); + $"INSERT INTO \"tb_int_metrics\" (\"Id\", \"SequenceNumber\", \"Value\") VALUES ({i + 1}, {i}, {(double)(i * 10)})", + TestContext.Current.CancellationToken); } // Act — bucket SequenceNumber into groups of 5 @@ -204,7 +206,7 @@ await context.Database.ExecuteSqlInterpolatedAsync( Count = g.Count() }) .OrderBy(r => r.Bucket) - .ToListAsync(); + .ToListAsync(TestContext.Current.CancellationToken); // Assert — expect 4 buckets: [0-5), [5-10), [10-15), [15-20) Assert.Equal(4, results.Count); diff --git a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs index f2fec7d..6cdb019 100644 --- a/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs +++ b/tests/Eftdb.Tests/Integration/TimescaleDatabaseModelFactoryTests.cs @@ -27,10 +27,9 @@ public class TimescaleDatabaseModelFactoryTests : MigrationTestBase, IAsyncLifet private PostgreSqlContainer? _container; private string? _connectionString; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { - _container = new PostgreSqlBuilder() - .WithImage("timescale/timescaledb:latest-pg16") + _container = new PostgreSqlBuilder("timescale/timescaledb:latest-pg17") .WithDatabase("test_db") .WithUsername("test_user") .WithPassword("test_password") @@ -40,7 +39,7 @@ public async Task InitializeAsync() _connectionString = _container.GetConnectionString(); } - public async Task DisposeAsync() + public async ValueTask DisposeAsync() { if (_container != null) { diff --git a/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs b/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs new file mode 100644 index 0000000..53207d5 --- /dev/null +++ b/tests/Eftdb.Tests/Scaffolding/RetentionPolicyAnnotationApplierTests.cs @@ -0,0 +1,515 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using static CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding.RetentionPolicyScaffoldingExtractor; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Scaffolding; + +public class RetentionPolicyAnnotationApplierTests +{ + private readonly RetentionPolicyAnnotationApplier _applier = new(); + + private static DatabaseTable CreateTable(string name = "TestTable", string schema = "public") + { + return new DatabaseTable { Name = name, Schema = schema }; + } + + #region Should_Apply_Minimal_RetentionPolicy_With_DropAfter_And_All_Defaults + + [Fact] + public void Should_Apply_Minimal_RetentionPolicy_With_DropAfter_And_All_Defaults() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify mandatory annotations are set + Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); + Assert.Equal("30 days", table[RetentionPolicyAnnotations.DropAfter]); + Assert.Null(table[RetentionPolicyAnnotations.DropCreatedBefore]); + + // Optional annotations should NOT be set when using defaults + Assert.Null(table[RetentionPolicyAnnotations.InitialStart]); + Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); + Assert.Null(table[RetentionPolicyAnnotations.MaxRuntime]); + Assert.Null(table[RetentionPolicyAnnotations.MaxRetries]); + Assert.Null(table[RetentionPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_Minimal_RetentionPolicy_With_DropCreatedBefore + + [Fact] + public void Should_Apply_Minimal_RetentionPolicy_With_DropCreatedBefore() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: null, + DropCreatedBefore: "7 days", + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); + Assert.Null(table[RetentionPolicyAnnotations.DropAfter]); + Assert.Equal("7 days", table[RetentionPolicyAnnotations.DropCreatedBefore]); + } + + #endregion + + #region Should_Apply_HasRetentionPolicy_Always_True + + [Fact] + public void Should_Apply_HasRetentionPolicy_Always_True() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + object? value = table[RetentionPolicyAnnotations.HasRetentionPolicy]; + Assert.NotNull(value); + Assert.IsType(value); + Assert.True((bool)value); + } + + #endregion + + #region Should_Apply_InitialStart_Annotation + + [Fact] + public void Should_Apply_InitialStart_Annotation() + { + // Arrange + DatabaseTable table = CreateTable(); + DateTime initialStart = new(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: initialStart, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(initialStart, table[RetentionPolicyAnnotations.InitialStart]); + } + + #endregion + + #region Should_Not_Apply_InitialStart_When_Null + + [Fact] + public void Should_Not_Apply_InitialStart_When_Null() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[RetentionPolicyAnnotations.InitialStart]); + } + + #endregion + + #region Should_Apply_ScheduleInterval_When_Different_From_Default + + [Fact] + public void Should_Apply_ScheduleInterval_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: "7 days", + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("7 days", table[RetentionPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Not_Apply_ScheduleInterval_When_Default + + [Fact] + public void Should_Not_Apply_ScheduleInterval_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, // "1 day" + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); + } + + #endregion + + #region Should_Apply_MaxRuntime_When_Different_From_Default + + [Fact] + public void Should_Apply_MaxRuntime_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: "01:00:00", + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("01:00:00", table[RetentionPolicyAnnotations.MaxRuntime]); + } + + #endregion + + #region Should_Not_Apply_MaxRuntime_When_Default + + [Fact] + public void Should_Not_Apply_MaxRuntime_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, // "00:00:00" + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[RetentionPolicyAnnotations.MaxRuntime]); + } + + #endregion + + #region Should_Apply_MaxRetries_When_Different_From_Default + + [Fact] + public void Should_Apply_MaxRetries_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: 5, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(5, table[RetentionPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Not_Apply_MaxRetries_When_Default + + [Fact] + public void Should_Not_Apply_MaxRetries_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, // -1 + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[RetentionPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Apply_MaxRetries_Zero + + [Fact] + public void Should_Apply_MaxRetries_Zero() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: 0, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - 0 is different from default (-1), so it should be applied + Assert.Equal(0, table[RetentionPolicyAnnotations.MaxRetries]); + } + + #endregion + + #region Should_Apply_RetryPeriod_When_Different_From_Default + + [Fact] + public void Should_Apply_RetryPeriod_When_Different_From_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: "00:15:00" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal("00:15:00", table[RetentionPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Not_Apply_RetryPeriod_When_Default + + [Fact] + public void Should_Not_Apply_RetryPeriod_When_Default() + { + // Arrange + DatabaseTable table = CreateTable(); + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval // "1 day" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Null(table[RetentionPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_All_Annotations_For_Fully_Configured_RetentionPolicy + + [Fact] + public void Should_Apply_All_Annotations_For_Fully_Configured_RetentionPolicy() + { + // Arrange + DatabaseTable table = CreateTable("sensor_readings", "telemetry"); + DateTime initialStart = new(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc); + RetentionPolicyInfo info = new( + DropAfter: "90 days", + DropCreatedBefore: null, + InitialStart: initialStart, + ScheduleInterval: "12 hours", + MaxRuntime: "02:00:00", + MaxRetries: 3, + RetryPeriod: "00:10:00" + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - verify ALL annotations are applied + Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); + Assert.Equal("90 days", table[RetentionPolicyAnnotations.DropAfter]); + Assert.Null(table[RetentionPolicyAnnotations.DropCreatedBefore]); + Assert.Equal(initialStart, table[RetentionPolicyAnnotations.InitialStart]); + Assert.Equal("12 hours", table[RetentionPolicyAnnotations.ScheduleInterval]); + Assert.Equal("02:00:00", table[RetentionPolicyAnnotations.MaxRuntime]); + Assert.Equal(3, table[RetentionPolicyAnnotations.MaxRetries]); + Assert.Equal("00:10:00", table[RetentionPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Apply_Only_Non_Default_Annotations + + [Fact] + public void Should_Apply_Only_Non_Default_Annotations() + { + // Arrange + DatabaseTable table = CreateTable(); + DateTime initialStart = new(2024, 3, 15, 8, 0, 0, DateTimeKind.Utc); + RetentionPolicyInfo info = new( + DropAfter: "60 days", + DropCreatedBefore: null, + InitialStart: initialStart, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, // default - should NOT be applied + MaxRuntime: "01:30:00", // non-default - should be applied + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, // default - should NOT be applied + RetryPeriod: "00:15:00" // non-default - should be applied + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert + Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); + Assert.Equal("60 days", table[RetentionPolicyAnnotations.DropAfter]); + Assert.Equal(initialStart, table[RetentionPolicyAnnotations.InitialStart]); + Assert.Null(table[RetentionPolicyAnnotations.ScheduleInterval]); // default + Assert.Equal("01:30:00", table[RetentionPolicyAnnotations.MaxRuntime]); + Assert.Null(table[RetentionPolicyAnnotations.MaxRetries]); // default + Assert.Equal("00:15:00", table[RetentionPolicyAnnotations.RetryPeriod]); + } + + #endregion + + #region Should_Throw_ArgumentException_For_Invalid_Info_Type + + [Fact] + public void Should_Throw_ArgumentException_For_Invalid_Info_Type() + { + // Arrange + DatabaseTable table = CreateTable(); + object invalidInfo = new { DropAfter = "30 days" }; + + // Act & Assert + ArgumentException exception = Assert.Throws( + () => _applier.ApplyAnnotations(table, invalidInfo) + ); + + Assert.Equal("featureInfo", exception.ParamName); + Assert.Contains("Expected RetentionPolicyInfo", exception.Message); + } + + #endregion + + #region Should_Preserve_Existing_Table_Properties + + [Fact] + public void Should_Preserve_Existing_Table_Properties() + { + // Arrange + DatabaseTable table = CreateTable("existing_table", "custom_schema"); + table.Comment = "Pre-existing table comment"; + RetentionPolicyInfo info = new( + DropAfter: "30 days", + DropCreatedBefore: null, + InitialStart: null, + ScheduleInterval: DefaultValues.RetentionPolicyScheduleInterval, + MaxRuntime: DefaultValues.RetentionPolicyMaxRuntime, + MaxRetries: DefaultValues.RetentionPolicyMaxRetries, + RetryPeriod: DefaultValues.RetentionPolicyScheduleInterval + ); + + // Act + _applier.ApplyAnnotations(table, info); + + // Assert - table properties should be preserved + Assert.Equal("existing_table", table.Name); + Assert.Equal("custom_schema", table.Schema); + Assert.Equal("Pre-existing table comment", table.Comment); + + // And annotations should still be applied + Assert.Equal(true, table[RetentionPolicyAnnotations.HasRetentionPolicy]); + Assert.Equal("30 days", table[RetentionPolicyAnnotations.DropAfter]); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs new file mode 100644 index 0000000..4218220 --- /dev/null +++ b/tests/Eftdb.Tests/TypeBuilders/RetentionPolicyTypeBuilderTests.cs @@ -0,0 +1,737 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.RetentionPolicy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests that verify RetentionPolicyTypeBuilder Fluent API methods correctly apply annotations. +/// +public class RetentionPolicyTypeBuilderTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + #region WithRetentionPolicy_Should_Set_HasRetentionPolicy_Annotation + + private class HasRetentionPolicyEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class HasRetentionPolicyContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_HasRetentionPolicy_Annotation() + { + using HasRetentionPolicyContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(HasRetentionPolicyEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_DropAfter_Annotation + + private class DropAfterEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropAfterContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_DropAfter_Annotation() + { + using DropAfterContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DropAfterEntity))!; + + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_DropCreatedBefore_Annotation + + private class DropCreatedBeforeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class DropCreatedBeforeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropCreatedBefore: "30 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_DropCreatedBefore_Annotation() + { + using DropCreatedBeforeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(DropCreatedBeforeEntity))!; + + Assert.Equal("30 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropCreatedBefore)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified + + private class BothSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class BothSpecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days", dropCreatedBefore: "30 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Throw_When_Both_DropAfter_And_DropCreatedBefore_Specified() + { + using BothSpecifiedContext context = new(); + + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("mutually exclusive", exception.Message); + } + + #endregion + + #region WithRetentionPolicy_Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified + + private class NeitherSpecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NeitherSpecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Throw_When_Neither_DropAfter_Nor_DropCreatedBefore_Specified() + { + using NeitherSpecifiedContext context = new(); + + InvalidOperationException exception = Assert.Throws(() => GetModel(context)); + Assert.Contains("Exactly one", exception.Message); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_InitialStart_When_Provided + + private class InitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class InitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc) + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_InitialStart_When_Provided() + { + using InitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(InitialStartEntity))!; + + object? initialStartValue = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + Assert.IsType(initialStartValue); + + DateTime initialStart = (DateTime)initialStartValue; + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), initialStart); + } + + #endregion + + #region WithRetentionPolicy_Should_Not_Set_InitialStart_When_Null + + private class NoInitialStartEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoInitialStartContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Not_Set_InitialStart_When_Null() + { + using NoInitialStartContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoInitialStartEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_ScheduleInterval_When_Provided + + private class ScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + scheduleInterval: "1 day" + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_ScheduleInterval_When_Provided() + { + using ScheduleIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(ScheduleIntervalEntity))!; + + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Not_Set_ScheduleInterval_When_Null + + private class NoScheduleIntervalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoScheduleIntervalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Not_Set_ScheduleInterval_When_Null() + { + using NoScheduleIntervalContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoScheduleIntervalEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_MaxRuntime_When_Provided + + private class MaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRuntime: "01:00:00" + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_MaxRuntime_When_Provided() + { + using MaxRuntimeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRuntimeEntity))!; + + Assert.Equal("01:00:00", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Not_Set_MaxRuntime_When_Null + + private class NoMaxRuntimeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoMaxRuntimeContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Not_Set_MaxRuntime_When_Null() + { + using NoMaxRuntimeContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoMaxRuntimeEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_MaxRetries_When_Provided + + private class MaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + maxRetries: 5 + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_MaxRetries_When_Provided() + { + using MaxRetriesContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MaxRetriesEntity))!; + + Assert.Equal(5, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Not_Set_MaxRetries_When_Null + + private class NoMaxRetriesEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoMaxRetriesContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Not_Set_MaxRetries_When_Null() + { + using NoMaxRetriesContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoMaxRetriesEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)); + } + + #endregion + + #region WithRetentionPolicy_Should_Set_RetryPeriod_When_Provided + + private class RetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + retryPeriod: "00:10:00" + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Set_RetryPeriod_When_Provided() + { + using RetryPeriodContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(RetryPeriodEntity))!; + + Assert.Equal("00:10:00", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Not_Set_RetryPeriod_When_Null + + private class NoRetryPeriodEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class NoRetryPeriodContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Not_Set_RetryPeriod_When_Null() + { + using NoRetryPeriodContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(NoRetryPeriodEntity))!; + + Assert.Null(entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)); + } + + #endregion + + #region WithRetentionPolicy_Should_Support_All_Parameters + + private class FullyConfiguredEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class FullyConfiguredContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + scheduleInterval: "1 day", + maxRuntime: "02:00:00", + maxRetries: 3, + retryPeriod: "00:15:00" + ); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Support_All_Parameters() + { + using FullyConfiguredContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(FullyConfiguredEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + + object? initialStartValue = entityType.FindAnnotation(RetentionPolicyAnnotations.InitialStart)?.Value; + Assert.NotNull(initialStartValue); + DateTime initialStart = (DateTime)initialStartValue; + Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), initialStart); + + Assert.Equal("1 day", entityType.FindAnnotation(RetentionPolicyAnnotations.ScheduleInterval)?.Value); + Assert.Equal("02:00:00", entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRuntime)?.Value); + Assert.Equal(3, entityType.FindAnnotation(RetentionPolicyAnnotations.MaxRetries)?.Value); + Assert.Equal("00:15:00", entityType.FindAnnotation(RetentionPolicyAnnotations.RetryPeriod)?.Value); + } + + #endregion + + #region WithRetentionPolicy_Should_Return_EntityTypeBuilder_For_Chaining + + private class MethodChainingEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class MethodChainingContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("Metrics"); + entity.IsHypertable(x => x.Timestamp) + .WithRetentionPolicy(dropAfter: "7 days"); + }); + } + } + + [Fact] + public void WithRetentionPolicy_Should_Return_EntityTypeBuilder_For_Chaining() + { + using MethodChainingContext context = new(); + IModel model = GetModel(context); + IEntityType entityType = model.FindEntityType(typeof(MethodChainingEntity))!; + + Assert.Equal(true, entityType.FindAnnotation(HypertableAnnotations.IsHypertable)?.Value); + Assert.Equal("Timestamp", entityType.FindAnnotation(HypertableAnnotations.HypertableTimeColumn)?.Value); + Assert.Equal(true, entityType.FindAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy)?.Value); + Assert.Equal("7 days", entityType.FindAnnotation(RetentionPolicyAnnotations.DropAfter)?.Value); + } + + #endregion +}