diff --git a/src/Eftdb.Design/Features/CompressionPolicy/CompressionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Features/CompressionPolicy/CompressionPolicyScaffoldingExtractor.cs index 2bf1813..abc7e2a 100644 --- a/src/Eftdb.Design/Features/CompressionPolicy/CompressionPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Features/CompressionPolicy/CompressionPolicyScaffoldingExtractor.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; using System.Data.Common; using System.Text.Json; @@ -56,7 +57,7 @@ FROM _timescaledb_catalog.hypertable 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); + DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3)); string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); string? timezone = reader.IsDBNull(5) ? null : reader.GetString(5); diff --git a/src/Eftdb.Design/Features/ContinuousAggregatePolicy/ContinuousAggregatePolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Features/ContinuousAggregatePolicy/ContinuousAggregatePolicyScaffoldingExtractor.cs index a0512cf..a7d3e26 100644 --- a/src/Eftdb.Design/Features/ContinuousAggregatePolicy/ContinuousAggregatePolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Features/ContinuousAggregatePolicy/ContinuousAggregatePolicyScaffoldingExtractor.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; using System.Data.Common; using System.Text.Json; @@ -47,7 +48,7 @@ INNER JOIN _timescaledb_catalog.continuous_agg ca string viewName = reader.GetString(1); string? configJson = reader.IsDBNull(2) ? null : reader.GetString(2); string? scheduleInterval = reader.IsDBNull(3) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(3)); - DateTime? initialStart = reader.IsDBNull(4) ? null : reader.GetDateTime(4); + DateTime? initialStart = reader.IsDBNull(4) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(4)); // Parse the JSONB config to extract policy parameters string? startOffset = null; diff --git a/src/Eftdb.Design/Features/ReorderPolicy/ReorderPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Features/ReorderPolicy/ReorderPolicyScaffoldingExtractor.cs index e884eb1..2ccc79f 100644 --- a/src/Eftdb.Design/Features/ReorderPolicy/ReorderPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Features/ReorderPolicy/ReorderPolicyScaffoldingExtractor.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; using System.Data.Common; @@ -42,7 +43,7 @@ FROM timescaledb_information.jobs AS j string schema = reader.GetString(0); string name = reader.GetString(1); string indexName = reader.GetString(2); - DateTime? initialStart = reader.IsDBNull(3) ? null : reader.GetDateTime(3); + DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3)); string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); diff --git a/src/Eftdb.Design/Features/RetentionPolicy/RetentionPolicyScaffoldingExtractor.cs b/src/Eftdb.Design/Features/RetentionPolicy/RetentionPolicyScaffoldingExtractor.cs index e6b37e7..62a5dbc 100644 --- a/src/Eftdb.Design/Features/RetentionPolicy/RetentionPolicyScaffoldingExtractor.cs +++ b/src/Eftdb.Design/Features/RetentionPolicy/RetentionPolicyScaffoldingExtractor.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding; using System.Data.Common; using System.Text.Json; @@ -45,7 +46,7 @@ FROM timescaledb_information.jobs AS j 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); + DateTime? initialStart = reader.IsDBNull(3) ? null : ConventionValidationHelper.NormalizeInitialStartToUtc(reader.GetDateTime(3)); string? scheduleInterval = reader.IsDBNull(4) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(4)); string? maxRuntime = reader.IsDBNull(5) ? null : IntervalParsingHelper.NormalizeInterval(reader.GetString(5)); int? maxRetries = reader.IsDBNull(6) ? null : reader.GetInt32(6); diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs index 58debb7..dd55fe9 100644 --- a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyStringBuilder.cs @@ -26,7 +26,7 @@ internal CompressionPolicyStringBuilder(EntityTypeBuilder builder) /// The builder for method chaining. public CompressionPolicyStringBuilder WithInitialStart(DateTime initialStart) { - _builder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart); + PolicyJobBuilderCore.WithInitialStart(_builder, CompressionPolicyAnnotations.InitialStart, initialStart); return this; } } diff --git a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs index 148d680..6ccb514 100644 --- a/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/CompressionPolicy/CompressionPolicyTypeBuilder.cs @@ -48,7 +48,7 @@ public static EntityTypeBuilder WithCompressionPolicy( WriteCompressionPolicy(entityTypeBuilder, after, createdBefore, scheduleInterval, timezone, ifNotExists); if (initialStart.HasValue) - entityTypeBuilder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart.Value); + PolicyJobBuilderCore.WithInitialStart(entityTypeBuilder, CompressionPolicyAnnotations.InitialStart, initialStart.Value); return entityTypeBuilder; } diff --git a/src/Eftdb/Configuration/ConventionValidationHelper.cs b/src/Eftdb/Configuration/ConventionValidationHelper.cs index eb38896..e3a3b4b 100644 --- a/src/Eftdb/Configuration/ConventionValidationHelper.cs +++ b/src/Eftdb/Configuration/ConventionValidationHelper.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration { /// @@ -54,13 +56,23 @@ internal static void ValidateExclusiveFields( } /// - /// Parses a from an attribute string value, throwing when - /// the value is present but cannot be parsed. + /// Parses a policy InitialStart from an attribute string value, + /// throwing when the value is present but cannot be parsed. The result is always + /// . /// + /// + /// Parsing uses with + /// | + /// so the produced instant does not depend on the machine's local time zone. Strings carrying an + /// explicit designator ("Z" or an offset) convert to UTC correctly; strings without a designator + /// are interpreted as already being UTC. Treating unsuffixed values as UTC is the only + /// machine-independent interpretation: the alternative (local time) would render a different + /// literal into migrations on every machine and produce phantom alter-policy operations. + /// /// The raw attribute string to parse. /// The CLR type name of the entity, for use in exception messages. /// The attribute name shown in the exception message prefix. - /// The parsed , or when is null or whitespace. + /// The parsed UTC , or when is null or whitespace. internal static DateTime? ParseInitialStart(string? rawValue, string? entityName, string attributeName) { if (string.IsNullOrWhiteSpace(rawValue)) @@ -68,7 +80,11 @@ internal static void ValidateExclusiveFields( return null; } - if (DateTime.TryParse(rawValue, out DateTime parsed)) + if (DateTime.TryParse( + rawValue, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsed)) { return parsed; } @@ -76,5 +92,39 @@ internal static void ValidateExclusiveFields( throw new InvalidOperationException( $"{attributeName} on '{entityName}': InitialStart '{rawValue}' is not a valid DateTime format. Use an ISO 8601 string."); } + + /// + /// Normalizes a policy InitialStart value to a machine-independent UTC instant so that + /// values written by the fluent API, parsed from attributes, and read back from snapshots all + /// compare on the same footing. + /// + /// + /// Kind handling: + /// + /// : returned unchanged. + /// : converted via . + /// : reinterpreted as UTC via + /// (NOT , which would + /// treat it as local and reintroduce a machine dependency). This matches the attribute path's + /// "unsuffixed = UTC" rule. + /// + /// + /// The value to normalize. + /// The equivalent UTC . + internal static DateTime NormalizeInitialStartToUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + + /// + /// Nullable overload of ; returns + /// unchanged. + /// + /// The value to normalize, or . + /// The equivalent UTC , or . + internal static DateTime? NormalizeInitialStartToUtc(DateTime? value) + => value.HasValue ? NormalizeInitialStartToUtc(value.Value) : null; } } diff --git a/src/Eftdb/Configuration/PolicyJobBuilderCore.cs b/src/Eftdb/Configuration/PolicyJobBuilderCore.cs index d55420f..ddc6ccb 100644 --- a/src/Eftdb/Configuration/PolicyJobBuilderCore.cs +++ b/src/Eftdb/Configuration/PolicyJobBuilderCore.cs @@ -40,10 +40,11 @@ internal readonly struct PolicyJobAnnotationKeys internal static class PolicyJobBuilderCore { /// - /// Writes the initial-start annotation. + /// Writes the initial-start annotation, normalizing the value to a machine-independent + /// UTC instant (see ). /// public static void WithInitialStart(EntityTypeBuilder builder, string annotationKey, DateTime initialStart) - => builder.HasAnnotation(annotationKey, initialStart); + => builder.HasAnnotation(annotationKey, ConventionValidationHelper.NormalizeInitialStartToUtc(initialStart)); /// /// Writes the if-not-exists annotation. diff --git a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs index 97bcfe1..d6f836d 100644 --- a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs +++ b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyStringBuilder.cs @@ -24,7 +24,7 @@ internal ReorderPolicyStringBuilder(EntityTypeBuilder builder) /// The builder for method chaining. public ReorderPolicyStringBuilder WithInitialStart(DateTime initialStart) { - _builder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart); + PolicyJobBuilderCore.WithInitialStart(_builder, ReorderPolicyAnnotations.InitialStart, initialStart); return this; } } diff --git a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs index feb32d3..df460ec 100644 --- a/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/ReorderPolicy/ReorderPolicyTypeBuilder.cs @@ -47,7 +47,7 @@ public static EntityTypeBuilder WithReorderPolicy( entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.IndexName, indexName); if (initialStart.HasValue) - entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart); + PolicyJobBuilderCore.WithInitialStart(entityTypeBuilder, ReorderPolicyAnnotations.InitialStart, initialStart.Value); if (!string.IsNullOrWhiteSpace(scheduleInterval)) entityTypeBuilder.HasAnnotation(ReorderPolicyAnnotations.ScheduleInterval, scheduleInterval); diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs index 65c66be..ef97b19 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyStringBuilder.cs @@ -26,7 +26,7 @@ internal RetentionPolicyStringBuilder(EntityTypeBuilder builder) /// The builder for method chaining. public RetentionPolicyStringBuilder WithInitialStart(DateTime initialStart) { - _builder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, initialStart); + PolicyJobBuilderCore.WithInitialStart(_builder, RetentionPolicyAnnotations.InitialStart, initialStart); return this; } } diff --git a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs index 2fc85c9..6841fbd 100644 --- a/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs +++ b/src/Eftdb/Configuration/RetentionPolicy/RetentionPolicyTypeBuilder.cs @@ -63,7 +63,7 @@ public static EntityTypeBuilder WithRetentionPolicy( entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.DropCreatedBefore, dropCreatedBefore!); if (initialStart.HasValue) - entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, initialStart); + PolicyJobBuilderCore.WithInitialStart(entityTypeBuilder, RetentionPolicyAnnotations.InitialStart, initialStart.Value); if (!string.IsNullOrWhiteSpace(scheduleInterval)) entityTypeBuilder.HasAnnotation(RetentionPolicyAnnotations.ScheduleInterval, scheduleInterval); diff --git a/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs index c32fdc0..1a20895 100644 --- a/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/CompressionPolicies/CompressionPolicyDiffer.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -47,7 +48,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source x.Target.Operation.After != x.Source.Operation.After || x.Target.Operation.CreatedBefore != x.Source.Operation.CreatedBefore || ScheduleIntervalChanged(x.Source, x.Target) || - x.Target.Operation.InitialStart != x.Source.Operation.InitialStart || + ConventionValidationHelper.NormalizeInitialStartToUtc(x.Target.Operation.InitialStart) != ConventionValidationHelper.NormalizeInitialStartToUtc(x.Source.Operation.InitialStart) || x.Target.Operation.Timezone != x.Source.Operation.Timezone ); diff --git a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs index 8f0ea24..7f71682 100644 --- a/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/ContinuousAggregatePolicies/ContinuousAggregatePolicyDiffer.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -86,7 +87,7 @@ private static bool ArePoliciesEqual(AddContinuousAggregatePolicyOperation sourc return source.StartOffset == target.StartOffset && source.EndOffset == target.EndOffset && source.ScheduleInterval == target.ScheduleInterval && - source.InitialStart == target.InitialStart && + ConventionValidationHelper.NormalizeInitialStartToUtc(source.InitialStart) == ConventionValidationHelper.NormalizeInitialStartToUtc(target.InitialStart) && source.IncludeTieredData == target.IncludeTieredData && source.BucketsPerBatch == target.BucketsPerBatch && source.MaxBatchesPerExecution == target.MaxBatchesPerExecution && diff --git a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs index 09241cc..f58a690 100644 --- a/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/ReorderPolicies/ReorderPolicyDiffer.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -31,7 +32,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source ) .Where(x => x.Target.IndexName != x.Source.IndexName || - x.Target.InitialStart != x.Source.InitialStart || + ConventionValidationHelper.NormalizeInitialStartToUtc(x.Target.InitialStart) != ConventionValidationHelper.NormalizeInitialStartToUtc(x.Source.InitialStart) || x.Target.ScheduleInterval != x.Source.ScheduleInterval || x.Target.MaxRuntime != x.Source.MaxRuntime || x.Target.MaxRetries != x.Source.MaxRetries || diff --git a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs index 6fac245..e96f541 100644 --- a/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs +++ b/src/Eftdb/Internals/Features/RetentionPolicies/RetentionPolicyDiffer.cs @@ -1,3 +1,4 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; using CmdScale.EntityFrameworkCore.TimescaleDB.Operations; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations.Operations; @@ -40,7 +41,7 @@ public IReadOnlyList GetDifferences(IRelationalModel? source .Where(x => x.Target.DropAfter != x.Source.DropAfter || x.Target.DropCreatedBefore != x.Source.DropCreatedBefore || - x.Target.InitialStart != x.Source.InitialStart || + ConventionValidationHelper.NormalizeInitialStartToUtc(x.Target.InitialStart) != ConventionValidationHelper.NormalizeInitialStartToUtc(x.Source.InitialStart) || x.Target.ScheduleInterval != x.Source.ScheduleInterval || x.Target.MaxRuntime != x.Source.MaxRuntime || x.Target.MaxRetries != x.Source.MaxRetries || diff --git a/tests/Eftdb.Tests/Configuration/ConventionValidationHelperTests.cs b/tests/Eftdb.Tests/Configuration/ConventionValidationHelperTests.cs new file mode 100644 index 0000000..f53b2c0 --- /dev/null +++ b/tests/Eftdb.Tests/Configuration/ConventionValidationHelperTests.cs @@ -0,0 +1,212 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.Configuration; + +/// +/// Tests that verify ConventionValidationHelper normalizes and parses policy InitialStart values +/// to machine-independent UTC regardless of the host time zone. +/// +public class ConventionValidationHelperTests +{ + // ── NormalizeInitialStartToUtc ───────────────────────────────────────────── + + #region NormalizeInitialStartToUtc_Utc_Returns_Unchanged + + [Fact] + public void NormalizeInitialStartToUtc_Utc_Returns_Unchanged() + { + // Arrange + DateTime value = new(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc); + + // Act + DateTime result = ConventionValidationHelper.NormalizeInitialStartToUtc(value); + + // Assert + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(value, result); + } + + #endregion + + #region NormalizeInitialStartToUtc_Local_Converts_To_Correct_Instant + + [Fact] + public void NormalizeInitialStartToUtc_Local_Converts_To_Correct_Instant() + { + // Arrange + DateTime value = new(2025, 9, 23, 9, 15, 19, DateTimeKind.Local); + DateTime expected = value.ToUniversalTime(); + + // Act + DateTime result = ConventionValidationHelper.NormalizeInitialStartToUtc(value); + + // Assert + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(expected, result); + } + + #endregion + + #region NormalizeInitialStartToUtc_Unspecified_Keeps_WallClock_Gains_Utc_Kind + + [Fact] + public void NormalizeInitialStartToUtc_Unspecified_Keeps_WallClock_Gains_Utc_Kind() + { + // Arrange + DateTime value = new(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified); + + // Act + DateTime result = ConventionValidationHelper.NormalizeInitialStartToUtc(value); + + // Assert + Assert.Equal(DateTimeKind.Utc, result.Kind); + Assert.Equal(value.Year, result.Year); + Assert.Equal(value.Month, result.Month); + Assert.Equal(value.Day, result.Day); + Assert.Equal(value.Hour, result.Hour); + Assert.Equal(value.Minute, result.Minute); + Assert.Equal(value.Second, result.Second); + } + + #endregion + + #region NormalizeInitialStartToUtc_Nullable_Null_Passes_Through + + [Fact] + public void NormalizeInitialStartToUtc_Nullable_Null_Passes_Through() + { + // Arrange + DateTime? value = null; + + // Act + DateTime? result = ConventionValidationHelper.NormalizeInitialStartToUtc(value); + + // Assert + Assert.Null(result); + } + + #endregion + + #region NormalizeInitialStartToUtc_Nullable_Local_Converts_To_Correct_Instant + + [Fact] + public void NormalizeInitialStartToUtc_Nullable_Local_Converts_To_Correct_Instant() + { + // Arrange + DateTime? value = new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Local); + DateTime expected = value.Value.ToUniversalTime(); + + // Act + DateTime? result = ConventionValidationHelper.NormalizeInitialStartToUtc(value); + + // Assert + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result.Value.Kind); + Assert.Equal(expected, result.Value); + } + + #endregion + + // ── ParseInitialStart ────────────────────────────────────────────────────── + + #region ParseInitialStart_Z_Suffix_Returns_Utc_With_Unshifted_Digits + + [Fact] + public void ParseInitialStart_Z_Suffix_Returns_Utc_With_Unshifted_Digits() + { + // Arrange + string raw = "2025-09-23T09:15:19Z"; + + // Act + DateTime? result = ConventionValidationHelper.ParseInitialStart(raw, "Entity", "[ReorderPolicy]"); + + // Assert + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result.Value.Kind); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), result.Value); + } + + #endregion + + #region ParseInitialStart_Explicit_Offset_Shifts_To_Utc + + [Fact] + public void ParseInitialStart_Explicit_Offset_Shifts_To_Utc() + { + // Arrange + string raw = "2025-09-23T09:15:19+02:00"; + + // Act + DateTime? result = ConventionValidationHelper.ParseInitialStart(raw, "Entity", "[ReorderPolicy]"); + + // Assert + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result.Value.Kind); + Assert.Equal(new DateTime(2025, 9, 23, 7, 15, 19, DateTimeKind.Utc), result.Value); + } + + #endregion + + #region ParseInitialStart_Unsuffixed_Treated_As_Utc + + [Fact] + public void ParseInitialStart_Unsuffixed_Treated_As_Utc() + { + // Arrange + string raw = "2025-09-23T09:15:19"; + + // Act + DateTime? result = ConventionValidationHelper.ParseInitialStart(raw, "Entity", "[ReorderPolicy]"); + + // Assert + Assert.NotNull(result); + Assert.Equal(DateTimeKind.Utc, result.Value.Kind); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), result.Value); + } + + #endregion + + #region ParseInitialStart_Garbage_Throws_InvalidOperationException + + [Fact] + public void ParseInitialStart_Garbage_Throws_InvalidOperationException() + { + // Arrange + string raw = "not-a-date"; + + // Act & Assert + InvalidOperationException ex = Assert.Throws( + () => ConventionValidationHelper.ParseInitialStart(raw, "Entity", "[ReorderPolicy]")); + Assert.Contains("not a valid DateTime format", ex.Message); + } + + #endregion + + #region ParseInitialStart_Null_Returns_Null + + [Fact] + public void ParseInitialStart_Null_Returns_Null() + { + // Arrange & Act + DateTime? result = ConventionValidationHelper.ParseInitialStart(null, "Entity", "[ReorderPolicy]"); + + // Assert + Assert.Null(result); + } + + #endregion + + #region ParseInitialStart_Whitespace_Returns_Null + + [Fact] + public void ParseInitialStart_Whitespace_Returns_Null() + { + // Arrange & Act + DateTime? result = ConventionValidationHelper.ParseInitialStart(" ", "Entity", "[ReorderPolicy]"); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Conventions/PolicyInitialStartConventionTests.cs b/tests/Eftdb.Tests/Conventions/PolicyInitialStartConventionTests.cs new file mode 100644 index 0000000..ccff76f --- /dev/null +++ b/tests/Eftdb.Tests/Conventions/PolicyInitialStartConventionTests.cs @@ -0,0 +1,169 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.Hypertable; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ReorderPolicy; +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 the policy conventions parse the InitialStart attribute string into a +/// Utc-kind DateTime annotation, independent of the host time zone. +/// +public class PolicyInitialStartConventionTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + private static DateTime AssertUtcDateTime(IEntityType entityType, string annotationKey) + { + object? value = entityType.FindAnnotation(annotationKey)?.Value; + Assert.NotNull(value); + Assert.IsType(value); + DateTime dateTime = (DateTime)value; + Assert.Equal(DateTimeKind.Utc, dateTime.Kind); + return dateTime; + } + + // ── Reorder policy attribute ─────────────────────────────────────────────── + + #region Reorder_Attribute_Z_Suffix_Produces_Utc_Annotation + + [Hypertable("Timestamp")] + [ReorderPolicy("reorder_attr_idx", InitialStart = "2025-09-23T09:15:19Z")] + private class ReorderAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderAttributeContext : 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("reorder_attr"); + }); + } + } + + [Fact] + public void Reorder_Attribute_Z_Suffix_Produces_Utc_Annotation() + { + // Arrange + using ReorderAttributeContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderAttributeEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + // ── Retention policy attribute ───────────────────────────────────────────── + + #region Retention_Attribute_Z_Suffix_Produces_Utc_Annotation + + [Hypertable("Timestamp")] + [RetentionPolicy(DropAfter = "7 days", InitialStart = "2025-09-23T09:15:19Z")] + private class RetentionAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionAttributeContext : 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("retention_attr"); + }); + } + } + + [Fact] + public void Retention_Attribute_Z_Suffix_Produces_Utc_Annotation() + { + // Arrange + using RetentionAttributeContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionAttributeEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + // ── Compression policy attribute ─────────────────────────────────────────── + + #region Compression_Attribute_Z_Suffix_Produces_Utc_Annotation + + [Hypertable("Timestamp")] + [CompressionPolicy(After = "7 days", InitialStart = "2025-09-23T09:15:19Z")] + private class CompressionAttributeEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionAttributeContext : 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("compression_attr"); + }); + } + } + + [Fact] + public void Compression_Attribute_Z_Suffix_Produces_Utc_Annotation() + { + // Arrange + using CompressionAttributeContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionAttributeEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/Differs/PolicyInitialStartDifferTests.cs b/tests/Eftdb.Tests/Differs/PolicyInitialStartDifferTests.cs new file mode 100644 index 0000000..0d2d9cb --- /dev/null +++ b/tests/Eftdb.Tests/Differs/PolicyInitialStartDifferTests.cs @@ -0,0 +1,677 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +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.Features.CompressionPolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ContinuousAggregatePolicies; +using CmdScale.EntityFrameworkCore.TimescaleDB.Internals.Features.ReorderPolicies; +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; + +/// +/// Tests that verify every policy differ compares InitialStart kind-insensitively: a Local-kind +/// annotation and the equivalent Utc-kind annotation must not produce an alter/recreate operation, +/// while a genuinely different instant must. +/// +public class PolicyInitialStartDifferTests +{ + private static IRelationalModel GetModel(DbContext context) + { + return context.GetService().Model.GetRelationalModel(); + } + + private static readonly DateTime UtcInstant = new(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc); + private static readonly DateTime LocalEquivalent = UtcInstant.ToLocalTime(); + private static readonly DateTime DifferentUtcInstant = new(2026, 3, 14, 1, 2, 3, DateTimeKind.Utc); + + // ── Reorder policy differ ────────────────────────────────────────────────── + + #region Reorder_Local_Vs_Equivalent_Utc_Emits_No_Alter + + private class ReorderMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderLocalSourceContext1 : 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("reorder_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + entity.HasAnnotation(ReorderPolicyAnnotations.IndexName, "reorder_diff_idx"); + entity.HasAnnotation(ReorderPolicyAnnotations.InitialStart, LocalEquivalent); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("reorder_diff_idx"); + }); + } + } + + private class ReorderUtcTargetContext1 : 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("reorder_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + entity.HasAnnotation(ReorderPolicyAnnotations.IndexName, "reorder_diff_idx"); + entity.HasAnnotation(ReorderPolicyAnnotations.InitialStart, UtcInstant); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("reorder_diff_idx"); + }); + } + } + + [Fact] + public void Reorder_Local_Vs_Equivalent_Utc_Emits_No_Alter() + { + // Arrange + using ReorderLocalSourceContext1 sourceContext = new(); + using ReorderUtcTargetContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + ReorderPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Reorder_Different_Instant_Emits_Alter + + private class ReorderMetric2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderSourceContext2 : 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("reorder_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + entity.HasAnnotation(ReorderPolicyAnnotations.IndexName, "reorder_diff2_idx"); + entity.HasAnnotation(ReorderPolicyAnnotations.InitialStart, UtcInstant); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("reorder_diff2_idx"); + }); + } + } + + private class ReorderTargetContext2 : 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("reorder_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(ReorderPolicyAnnotations.HasReorderPolicy, true); + entity.HasAnnotation(ReorderPolicyAnnotations.IndexName, "reorder_diff2_idx"); + entity.HasAnnotation(ReorderPolicyAnnotations.InitialStart, DifferentUtcInstant); + entity.HasIndex(x => x.Timestamp).HasDatabaseName("reorder_diff2_idx"); + }); + } + } + + [Fact] + public void Reorder_Different_Instant_Emits_Alter() + { + // Arrange + using ReorderSourceContext2 sourceContext = new(); + using ReorderTargetContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + ReorderPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterReorderPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(DifferentUtcInstant, alterOp.InitialStart); + } + + #endregion + + // ── Retention policy differ ──────────────────────────────────────────────── + + #region Retention_Local_Vs_Equivalent_Utc_Emits_No_Alter + + private class RetentionMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionLocalSourceContext1 : 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("retention_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + entity.HasAnnotation(RetentionPolicyAnnotations.InitialStart, LocalEquivalent); + }); + } + } + + private class RetentionUtcTargetContext1 : 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("retention_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + entity.HasAnnotation(RetentionPolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + [Fact] + public void Retention_Local_Vs_Equivalent_Utc_Emits_No_Alter() + { + // Arrange + using RetentionLocalSourceContext1 sourceContext = new(); + using RetentionUtcTargetContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + RetentionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Retention_Different_Instant_Emits_Alter + + private class RetentionMetric2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionSourceContext2 : 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("retention_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + entity.HasAnnotation(RetentionPolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + private class RetentionTargetContext2 : 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("retention_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(RetentionPolicyAnnotations.HasRetentionPolicy, true); + entity.HasAnnotation(RetentionPolicyAnnotations.DropAfter, "7 days"); + entity.HasAnnotation(RetentionPolicyAnnotations.InitialStart, DifferentUtcInstant); + }); + } + } + + [Fact] + public void Retention_Different_Instant_Emits_Alter() + { + // Arrange + using RetentionSourceContext2 sourceContext = new(); + using RetentionTargetContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + RetentionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterRetentionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(DifferentUtcInstant, alterOp.InitialStart); + } + + #endregion + + // ── Compression policy differ ────────────────────────────────────────────── + + #region Compression_Local_Vs_Equivalent_Utc_Emits_No_Alter + + private class CompressionMetric1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionLocalSourceContext1 : 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("compression_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + entity.HasAnnotation(CompressionPolicyAnnotations.After, "7 days"); + entity.HasAnnotation(CompressionPolicyAnnotations.InitialStart, LocalEquivalent); + }); + } + } + + private class CompressionUtcTargetContext1 : 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("compression_diff_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + entity.HasAnnotation(CompressionPolicyAnnotations.After, "7 days"); + entity.HasAnnotation(CompressionPolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + [Fact] + public void Compression_Local_Vs_Equivalent_Utc_Emits_No_Alter() + { + // Arrange + using CompressionLocalSourceContext1 sourceContext = new(); + using CompressionUtcTargetContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + } + + #endregion + + #region Compression_Different_Instant_Emits_Alter + + private class CompressionMetric2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionSourceContext2 : 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("compression_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + entity.HasAnnotation(CompressionPolicyAnnotations.After, "7 days"); + entity.HasAnnotation(CompressionPolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + private class CompressionTargetContext2 : 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("compression_diff2_metrics"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + entity.HasAnnotation(CompressionPolicyAnnotations.HasCompressionPolicy, true); + entity.HasAnnotation(CompressionPolicyAnnotations.After, "7 days"); + entity.HasAnnotation(CompressionPolicyAnnotations.InitialStart, DifferentUtcInstant); + }); + } + } + + [Fact] + public void Compression_Different_Instant_Emits_Alter() + { + // Arrange + using CompressionSourceContext2 sourceContext = new(); + using CompressionTargetContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + CompressionPolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + AlterCompressionPolicyOperation? alterOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(alterOp); + Assert.Equal(DifferentUtcInstant, alterOp.InitialStart); + } + + #endregion + + // ── Continuous aggregate policy differ ───────────────────────────────────── + + #region CAggPolicy_Local_Vs_Equivalent_Utc_Emits_No_Operations + + private class CAggSource1 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CAggView1 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CAggLocalSourceContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_diff_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_diff_view", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, LocalEquivalent); + }); + } + } + + private class CAggUtcTargetContext1 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_diff_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_diff_view", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + [Fact] + public void CAggPolicy_Local_Vs_Equivalent_Utc_Emits_No_Operations() + { + // Arrange + using CAggLocalSourceContext1 sourceContext = new(); + using CAggUtcTargetContext1 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + ContinuousAggregatePolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.Empty(operations.OfType()); + Assert.Empty(operations.OfType()); + } + + #endregion + + #region CAggPolicy_Different_Instant_Emits_Recreate + + private class CAggSource2 + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CAggView2 + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CAggSourceContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_diff2_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_diff2_view", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, UtcInstant); + }); + } + } + + private class CAggTargetContext2 : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_diff2_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_diff2_view", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour"); + entity.HasAnnotation(ContinuousAggregatePolicyAnnotations.InitialStart, DifferentUtcInstant); + }); + } + } + + [Fact] + public void CAggPolicy_Different_Instant_Emits_Recreate() + { + // Arrange + using CAggSourceContext2 sourceContext = new(); + using CAggTargetContext2 targetContext = new(); + + IRelationalModel sourceModel = GetModel(sourceContext); + IRelationalModel targetModel = GetModel(targetContext); + ContinuousAggregatePolicyDiffer differ = new(); + + // Act + IReadOnlyList operations = differ.GetDifferences(sourceModel, targetModel); + + // Assert + Assert.NotEmpty(operations.OfType()); + AddContinuousAggregatePolicyOperation? addOp = operations.OfType().FirstOrDefault(); + Assert.NotNull(addOp); + Assert.Equal(DifferentUtcInstant, addOp.InitialStart); + } + + #endregion +} diff --git a/tests/Eftdb.Tests/TypeBuilders/PolicyInitialStartNormalizationTests.cs b/tests/Eftdb.Tests/TypeBuilders/PolicyInitialStartNormalizationTests.cs new file mode 100644 index 0000000..4e51e89 --- /dev/null +++ b/tests/Eftdb.Tests/TypeBuilders/PolicyInitialStartNormalizationTests.cs @@ -0,0 +1,842 @@ +using CmdScale.EntityFrameworkCore.TimescaleDB.Abstractions; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.CompressionPolicy; +using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration.ContinuousAggregate; +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 Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CmdScale.EntityFrameworkCore.TimescaleDB.Tests.TypeBuilders; + +/// +/// Tests that verify every policy builder normalizes InitialStart to an unboxed Utc-kind DateTime +/// annotation regardless of the DateTimeKind supplied by the caller. +/// +public class PolicyInitialStartNormalizationTests +{ + private static IModel GetModel(DbContext context) + { + return context.GetService().Model; + } + + private static readonly DateTime UtcInstant = new(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc); + + private static DateTime AssertUtcDateTime(IEntityType entityType, string annotationKey) + { + object? value = entityType.FindAnnotation(annotationKey)?.Value; + Assert.NotNull(value); + Assert.IsType(value); + DateTime dateTime = (DateTime)value; + Assert.Equal(DateTimeKind.Utc, dateTime.Kind); + return dateTime; + } + + // ── Reorder policy: typed builder ────────────────────────────────────────── + + #region Reorder_TypedBuilder_Local_Stores_Utc_Instant + + private class ReorderTypedLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderTypedLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reorder_typed_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("reorder_typed_local_idx", initialStart: UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Reorder_TypedBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using ReorderTypedLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderTypedLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Reorder_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class ReorderTypedUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderTypedUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reorder_typed_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy( + "reorder_typed_unspecified_idx", + initialStart: new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Reorder_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using ReorderTypedUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderTypedUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + #region Reorder_TypedBuilder_Utc_Stores_Unchanged + + private class ReorderTypedUtcEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderTypedUtcContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reorder_typed_utc"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("reorder_typed_utc_idx", initialStart: UtcInstant); + }); + } + } + + [Fact] + public void Reorder_TypedBuilder_Utc_Stores_Unchanged() + { + // Arrange + using ReorderTypedUtcContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderTypedUtcEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + // ── Reorder policy: string builder ───────────────────────────────────────── + + #region Reorder_StringBuilder_Local_Stores_Utc_Instant + + private class ReorderStringLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderStringLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reorder_string_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("reorder_string_local_idx", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Reorder_StringBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using ReorderStringLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderStringLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Reorder_StringBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class ReorderStringUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class ReorderStringUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("reorder_string_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithReorderPolicy("reorder_string_unspecified_idx", (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Reorder_StringBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using ReorderStringUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(ReorderStringUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ReorderPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + // ── Retention policy: typed builder ──────────────────────────────────────── + + #region Retention_TypedBuilder_Local_Stores_Utc_Instant + + private class RetentionTypedLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionTypedLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("retention_typed_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days", initialStart: UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Retention_TypedBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using RetentionTypedLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionTypedLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Retention_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class RetentionTypedUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionTypedUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("retention_typed_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy( + dropAfter: "7 days", + initialStart: new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Retention_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using RetentionTypedUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionTypedUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + #region Retention_TypedBuilder_Utc_Stores_Unchanged + + private class RetentionTypedUtcEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionTypedUtcContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("retention_typed_utc"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy(dropAfter: "7 days", initialStart: UtcInstant); + }); + } + } + + [Fact] + public void Retention_TypedBuilder_Utc_Stores_Unchanged() + { + // Arrange + using RetentionTypedUtcContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionTypedUtcEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + // ── Retention policy: string builder ─────────────────────────────────────── + + #region Retention_StringBuilder_Local_Stores_Utc_Instant + + private class RetentionStringLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionStringLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("retention_string_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("7 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Retention_StringBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using RetentionStringLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionStringLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Retention_StringBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class RetentionStringUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class RetentionStringUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("retention_string_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithRetentionPolicy("7 days", (string?)null, (string?)null, (string?)null, (int?)null, (string?)null) + .WithInitialStart(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Retention_StringBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using RetentionStringUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(RetentionStringUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, RetentionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + // ── Compression policy: typed builder ────────────────────────────────────── + + #region Compression_TypedBuilder_Local_Stores_Utc_Instant + + private class CompressionTypedLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionTypedLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("compression_typed_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", initialStart: UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Compression_TypedBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using CompressionTypedLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionTypedLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Compression_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class CompressionTypedUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionTypedUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("compression_typed_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy( + after: "7 days", + initialStart: new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Compression_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using CompressionTypedUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionTypedUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + #region Compression_TypedBuilder_Utc_Stores_Unchanged + + private class CompressionTypedUtcEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionTypedUtcContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("compression_typed_utc"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy(after: "7 days", initialStart: UtcInstant); + }); + } + } + + [Fact] + public void Compression_TypedBuilder_Utc_Stores_Unchanged() + { + // Arrange + using CompressionTypedUtcContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionTypedUtcEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + // ── Compression policy: string builder ───────────────────────────────────── + + #region Compression_StringBuilder_Local_Stores_Utc_Instant + + private class CompressionStringLocalEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionStringLocalContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("compression_string_local"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy("7 days", (string?)null, (string?)null, (string?)null, (bool?)null) + .WithInitialStart(UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void Compression_StringBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using CompressionStringLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionStringLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region Compression_StringBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class CompressionStringUnspecifiedEntity + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CompressionStringUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=test;Username=test;Password=test") + .UseTimescaleDb(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.ToTable("compression_string_unspecified"); + entity.IsHypertable(x => x.Timestamp); + entity.WithCompressionPolicy("7 days", (string?)null, (string?)null, (string?)null, (bool?)null) + .WithInitialStart(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void Compression_StringBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using CompressionStringUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CompressionStringUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, CompressionPolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion + + // ── Continuous aggregate policy: typed builder ───────────────────────────── + + #region CAggPolicy_TypedBuilder_Local_Stores_Utc_Instant + + private class CAggMetricSourceLocal + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CAggViewLocalEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CAggPolicyLocalContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_policy_local_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_policy_local", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") + .WithInitialStart(UtcInstant.ToLocalTime()); + }); + } + } + + [Fact] + public void CAggPolicy_TypedBuilder_Local_Stores_Utc_Instant() + { + // Arrange + using CAggPolicyLocalContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CAggViewLocalEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ContinuousAggregatePolicyAnnotations.InitialStart); + Assert.Equal(UtcInstant, stored); + } + + #endregion + + #region CAggPolicy_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc + + private class CAggMetricSourceUnspecified + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } + + private class CAggViewUnspecifiedEntity + { + public DateTime TimeBucket { get; set; } + public double AvgValue { get; set; } + } + + private class CAggPolicyUnspecifiedContext : DbContext + { + public DbSet Metrics => Set(); + public DbSet Aggregates => 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("cagg_policy_unspecified_src"); + entity.HasNoKey(); + entity.IsHypertable(x => x.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasNoKey(); + entity.IsContinuousAggregate( + "cagg_policy_unspecified", "1 hour", x => x.Timestamp) + .AddAggregateFunction(x => x.AvgValue, x => x.Value, EAggregateFunction.Avg) + .WithRefreshPolicy(startOffset: "1 month", endOffset: "1 hour", scheduleInterval: "1 hour") + .WithInitialStart(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Unspecified)); + }); + } + } + + [Fact] + public void CAggPolicy_TypedBuilder_Unspecified_Stores_SpecifyKind_Utc() + { + // Arrange + using CAggPolicyUnspecifiedContext context = new(); + + // Act + IEntityType entityType = GetModel(context).FindEntityType(typeof(CAggViewUnspecifiedEntity))!; + + // Assert + DateTime stored = AssertUtcDateTime(entityType, ContinuousAggregatePolicyAnnotations.InitialStart); + Assert.Equal(new DateTime(2025, 9, 23, 9, 15, 19, DateTimeKind.Utc), stored); + } + + #endregion +}