Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
using System.Data.Common;
using System.Text.Json;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
using System.Data.Common;
using System.Text.Json;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
using System.Data.Common;

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Design.Scaffolding;
using System.Data.Common;
using System.Text.Json;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal CompressionPolicyStringBuilder(EntityTypeBuilder<TEntity> builder)
/// <returns>The builder for method chaining.</returns>
public CompressionPolicyStringBuilder<TEntity> WithInitialStart(DateTime initialStart)
{
_builder.HasAnnotation(CompressionPolicyAnnotations.InitialStart, initialStart);
PolicyJobBuilderCore.WithInitialStart(_builder, CompressionPolicyAnnotations.InitialStart, initialStart);
return this;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public static EntityTypeBuilder<TEntity> WithCompressionPolicy<TEntity>(
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;
}
Expand Down
58 changes: 54 additions & 4 deletions src/Eftdb/Configuration/ConventionValidationHelper.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Globalization;

namespace CmdScale.EntityFrameworkCore.TimescaleDB.Configuration
{
/// <summary>
Expand Down Expand Up @@ -54,27 +56,75 @@ internal static void ValidateExclusiveFields(
}

/// <summary>
/// Parses a <see cref="DateTime"/> from an attribute string value, throwing when
/// the value is present but cannot be parsed.
/// Parses a policy <c>InitialStart</c> <see cref="DateTime"/> from an attribute string value,
/// throwing when the value is present but cannot be parsed. The result is always
/// <see cref="DateTimeKind.Utc"/>.
/// </summary>
/// <remarks>
/// Parsing uses <see cref="CultureInfo.InvariantCulture"/> with
/// <see cref="DateTimeStyles.AssumeUniversal"/> | <see cref="DateTimeStyles.AdjustToUniversal"/>
/// 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.
/// </remarks>
/// <param name="rawValue">The raw attribute string to parse.</param>
/// <param name="entityName">The CLR type name of the entity, for use in exception messages.</param>
/// <param name="attributeName">The attribute name shown in the exception message prefix.</param>
/// <returns>The parsed <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns>
/// <returns>The parsed UTC <see cref="DateTime"/>, or <see langword="null"/> when <paramref name="rawValue"/> is null or whitespace.</returns>
internal static DateTime? ParseInitialStart(string? rawValue, string? entityName, string attributeName)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}

if (DateTime.TryParse(rawValue, out DateTime parsed))
if (DateTime.TryParse(
rawValue,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime parsed))
{
return parsed;
}

throw new InvalidOperationException(
$"{attributeName} on '{entityName}': InitialStart '{rawValue}' is not a valid DateTime format. Use an ISO 8601 string.");
}

/// <summary>
/// Normalizes a policy <c>InitialStart</c> 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.
/// </summary>
/// <remarks>
/// Kind handling:
/// <list type="bullet">
/// <item><see cref="DateTimeKind.Utc"/>: returned unchanged.</item>
/// <item><see cref="DateTimeKind.Local"/>: converted via <see cref="DateTime.ToUniversalTime"/>.</item>
/// <item><see cref="DateTimeKind.Unspecified"/>: reinterpreted as UTC via
/// <see cref="DateTime.SpecifyKind"/> (NOT <see cref="DateTime.ToUniversalTime"/>, which would
/// treat it as local and reintroduce a machine dependency). This matches the attribute path's
/// "unsuffixed = UTC" rule.</item>
/// </list>
/// </remarks>
/// <param name="value">The value to normalize.</param>
/// <returns>The equivalent UTC <see cref="DateTime"/>.</returns>
internal static DateTime NormalizeInitialStartToUtc(DateTime value) => value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
};

/// <summary>
/// Nullable overload of <see cref="NormalizeInitialStartToUtc(DateTime)"/>; returns
/// <see langword="null"/> unchanged.
/// </summary>
/// <param name="value">The value to normalize, or <see langword="null"/>.</param>
/// <returns>The equivalent UTC <see cref="DateTime"/>, or <see langword="null"/>.</returns>
internal static DateTime? NormalizeInitialStartToUtc(DateTime? value)
=> value.HasValue ? NormalizeInitialStartToUtc(value.Value) : null;
}
}
5 changes: 3 additions & 2 deletions src/Eftdb/Configuration/PolicyJobBuilderCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ internal readonly struct PolicyJobAnnotationKeys
internal static class PolicyJobBuilderCore
{
/// <summary>
/// Writes the initial-start annotation.
/// Writes the initial-start annotation, normalizing the value to a machine-independent
/// UTC instant (see <see cref="ConventionValidationHelper.NormalizeInitialStartToUtc(DateTime)"/>).
/// </summary>
public static void WithInitialStart(EntityTypeBuilder builder, string annotationKey, DateTime initialStart)
=> builder.HasAnnotation(annotationKey, initialStart);
=> builder.HasAnnotation(annotationKey, ConventionValidationHelper.NormalizeInitialStartToUtc(initialStart));

/// <summary>
/// Writes the if-not-exists annotation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal ReorderPolicyStringBuilder(EntityTypeBuilder<TEntity> builder)
/// <returns>The builder for method chaining.</returns>
public ReorderPolicyStringBuilder<TEntity> WithInitialStart(DateTime initialStart)
{
_builder.HasAnnotation(ReorderPolicyAnnotations.InitialStart, initialStart);
PolicyJobBuilderCore.WithInitialStart(_builder, ReorderPolicyAnnotations.InitialStart, initialStart);
return this;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public static EntityTypeBuilder<TEntity> WithReorderPolicy<TEntity>(
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal RetentionPolicyStringBuilder(EntityTypeBuilder<TEntity> builder)
/// <returns>The builder for method chaining.</returns>
public RetentionPolicyStringBuilder<TEntity> WithInitialStart(DateTime initialStart)
{
_builder.HasAnnotation(RetentionPolicyAnnotations.InitialStart, initialStart);
PolicyJobBuilderCore.WithInitialStart(_builder, RetentionPolicyAnnotations.InitialStart, initialStart);
return this;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static EntityTypeBuilder<TEntity> WithRetentionPolicy<TEntity>(
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
Expand Down Expand Up @@ -47,7 +48,7 @@ public IReadOnlyList<MigrationOperation> 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
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
Expand Down Expand Up @@ -31,7 +32,7 @@ public IReadOnlyList<MigrationOperation> 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 ||
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CmdScale.EntityFrameworkCore.TimescaleDB.Configuration;
using CmdScale.EntityFrameworkCore.TimescaleDB.Operations;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
Expand Down Expand Up @@ -40,7 +41,7 @@ public IReadOnlyList<MigrationOperation> 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 ||
Expand Down
Loading
Loading