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