From 742a11c3b11489ef040177c6a935b85842a8bd8e Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 19 Feb 2026 12:09:54 +0100 Subject: [PATCH] Additional work on temporal constraints Fix lack of NpgsqlRange comparer Closes #3739 Part of #2097 --- .../NpgsqlRangeCurrentValueComparer.cs | 112 +++++ ...NpgsqlPostgresModelFinalizingConvention.cs | 21 + .../NpgsqlRuntimeModelConvention.cs | 34 +- .../Properties/NpgsqlStrings.Designer.cs | 6 + src/EFCore.PG/Properties/NpgsqlStrings.resx | 3 + .../NpgsqlPeriodForeignKeyPostprocessor.cs | 146 ++++++ .../NpgsqlQueryTranslationPostprocessor.cs | 21 +- .../SpatialNpgsqlFixture.cs | 1 + .../TemporalConstraintTest.cs | 459 ++++++++++++++++++ .../NpgsqlModelValidatorTest.cs | 4 - 10 files changed, 775 insertions(+), 32 deletions(-) create mode 100644 src/EFCore.PG/ChangeTracking/Internal/NpgsqlRangeCurrentValueComparer.cs create mode 100644 src/EFCore.PG/Query/Internal/NpgsqlPeriodForeignKeyPostprocessor.cs create mode 100644 test/EFCore.PG.FunctionalTests/TemporalConstraintTest.cs diff --git a/src/EFCore.PG/ChangeTracking/Internal/NpgsqlRangeCurrentValueComparer.cs b/src/EFCore.PG/ChangeTracking/Internal/NpgsqlRangeCurrentValueComparer.cs new file mode 100644 index 0000000000..d96b8db329 --- /dev/null +++ b/src/EFCore.PG/ChangeTracking/Internal/NpgsqlRangeCurrentValueComparer.cs @@ -0,0 +1,112 @@ +using System.Collections; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.ChangeTracking.Internal; + +/// +/// An for values, providing an arbitrary but stable +/// total ordering. This is needed because does not implement +/// (ranges are only partially ordered), but EF Core requires an ordering for key properties +/// in the update pipeline. +/// +public sealed class NpgsqlRangeCurrentValueComparer(Type rangeClrType) : IComparer +{ + private readonly PropertyInfo _isEmptyProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.IsEmpty)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have an 'IsEmpty' property."); + private readonly PropertyInfo _lowerBoundProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.LowerBound)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have a 'LowerBound' property."); + private readonly PropertyInfo _upperBoundProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.UpperBound)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have an 'UpperBound' property."); + private readonly PropertyInfo _lowerBoundInfiniteProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.LowerBoundInfinite)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have a 'LowerBoundInfinite' property."); + private readonly PropertyInfo _upperBoundInfiniteProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.UpperBoundInfinite)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have an 'UpperBoundInfinite' property."); + private readonly PropertyInfo _lowerBoundIsInclusiveProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.LowerBoundIsInclusive)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have a 'LowerBoundIsInclusive' property."); + private readonly PropertyInfo _upperBoundIsInclusiveProperty = rangeClrType.GetProperty(nameof(NpgsqlRange<>.UpperBoundIsInclusive)) + ?? throw new ArgumentException($"Type '{rangeClrType}' does not have an 'UpperBoundIsInclusive' property."); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public int Compare(object? x, object? y) + { + if (x is null) + { + return y is null ? 0 : -1; + } + + if (y is null) + { + return 1; + } + + var xIsEmpty = (bool)_isEmptyProperty.GetValue(x)!; + var yIsEmpty = (bool)_isEmptyProperty.GetValue(y)!; + + if (xIsEmpty && yIsEmpty) + { + return 0; + } + + if (xIsEmpty) + { + return -1; + } + + if (yIsEmpty) + { + return 1; + } + + // Compare lower bounds + var cmp = CompareBound( + _lowerBoundProperty.GetValue(x), + (bool)_lowerBoundInfiniteProperty.GetValue(x)!, + _lowerBoundProperty.GetValue(y), + (bool)_lowerBoundInfiniteProperty.GetValue(y)!, + isLower: true); + + if (cmp != 0) + { + return cmp; + } + + // Compare lower bound inclusivity + cmp = ((bool)_lowerBoundIsInclusiveProperty.GetValue(x)!).CompareTo( + (bool)_lowerBoundIsInclusiveProperty.GetValue(y)!); + + if (cmp != 0) + { + return cmp; + } + + // Compare upper bounds + cmp = CompareBound( + _upperBoundProperty.GetValue(x), + (bool)_upperBoundInfiniteProperty.GetValue(x)!, + _upperBoundProperty.GetValue(y), + (bool)_upperBoundInfiniteProperty.GetValue(y)!, + isLower: false); + + if (cmp != 0) + { + return cmp; + } + + // Compare upper bound inclusivity + return ((bool)_upperBoundIsInclusiveProperty.GetValue(x)!).CompareTo( + (bool)_upperBoundIsInclusiveProperty.GetValue(y)!); + } + + private static int CompareBound(object? x, bool xInfinite, object? y, bool yInfinite, bool isLower) => + (xInfinite, yInfinite) switch + { + (true, true) => 0, + (true, false) => isLower ? -1 : 1, + (false, true) => isLower ? 1 : -1, + (false, false) => Comparer.Default.Compare(x, y), + }; +} diff --git a/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs b/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs index 4ef99f453c..03229f2b9a 100644 --- a/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs +++ b/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs @@ -1,3 +1,6 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.ChangeTracking.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; @@ -28,6 +31,7 @@ public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, { DiscoverPostgresExtensions(property, typeMapping, modelBuilder); ProcessRowVersionProperty(property, typeMapping); + SetRangeCurrentValueComparer(property, typeMapping); } } @@ -108,4 +112,21 @@ protected virtual void ProcessRowVersionProperty(IConventionProperty property, R property.Builder.HasColumnName("xmin"); } } + + /// + /// Pre-sets the current value comparer for range key/FK properties, since doesn't + /// implement and the default would reject it. + /// + protected virtual void SetRangeCurrentValueComparer(IConventionProperty property, RelationalTypeMapping typeMapping) + { + if ((property.IsKey() || property.IsForeignKey()) + && typeMapping is NpgsqlRangeTypeMapping + && property is PropertyBase propertyBase) + { +#pragma warning disable EF1001 // Internal EF Core API usage. + propertyBase.SetCurrentValueComparer( + new EntryCurrentValueComparer((IProperty)property, new NpgsqlRangeCurrentValueComparer(property.ClrType))); +#pragma warning restore EF1001 // Internal EF Core API usage. + } + } } diff --git a/src/EFCore.PG/Metadata/Conventions/NpgsqlRuntimeModelConvention.cs b/src/EFCore.PG/Metadata/Conventions/NpgsqlRuntimeModelConvention.cs index d932b07f0c..a51859816f 100644 --- a/src/EFCore.PG/Metadata/Conventions/NpgsqlRuntimeModelConvention.cs +++ b/src/EFCore.PG/Metadata/Conventions/NpgsqlRuntimeModelConvention.cs @@ -1,24 +1,20 @@ +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.ChangeTracking.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions; /// /// A convention that creates an optimized copy of the mutable model. /// -public class NpgsqlRuntimeModelConvention : RelationalRuntimeModelConvention +/// Parameter object containing dependencies for this convention. +/// Parameter object containing relational dependencies for this convention. +public class NpgsqlRuntimeModelConvention( + ProviderConventionSetBuilderDependencies dependencies, + RelationalConventionSetBuilderDependencies relationalDependencies) + : RelationalRuntimeModelConvention(dependencies, relationalDependencies) { - /// - /// Creates a new instance of . - /// - /// Parameter object containing dependencies for this convention. - /// Parameter object containing relational dependencies for this convention. - public NpgsqlRuntimeModelConvention( - ProviderConventionSetBuilderDependencies dependencies, - RelationalConventionSetBuilderDependencies relationalDependencies) - : base(dependencies, relationalDependencies) - { - } - /// protected override void ProcessModelAnnotations( Dictionary annotations, @@ -79,6 +75,18 @@ protected override void ProcessPropertyAnnotations( { base.ProcessPropertyAnnotations(annotations, property, runtimeProperty, runtime); + // NpgsqlRange doesn't implement IComparable (ranges are only partially ordered), so we must + // provide a custom CurrentValueComparer for the runtime model. Without this, the update pipeline's + // ModificationCommandComparer would fail when trying to sort commands by key values. + if ((property.IsKey() || property.IsForeignKey()) + && property.FindTypeMapping() is NpgsqlRangeTypeMapping) + { +#pragma warning disable EF1001 // Internal EF Core API usage. + runtimeProperty.SetCurrentValueComparer( + new EntryCurrentValueComparer(runtimeProperty, new NpgsqlRangeCurrentValueComparer(property.ClrType))); +#pragma warning restore EF1001 // Internal EF Core API usage. + } + if (!runtime) { annotations.Remove(NpgsqlAnnotationNames.IdentityOptions); diff --git a/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs b/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs index 230f25fe0e..edbad79e76 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs +++ b/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs @@ -183,6 +183,12 @@ public static string TwoDataSourcesInSameServiceProvider(object? useInternalServ public static string TransientExceptionDetected => GetString("TransientExceptionDetected"); + /// + /// Queries that join entities via a PERIOD foreign key (temporal constraint) cannot use change tracking. Use 'AsNoTracking()' to execute this query. + /// + public static string PeriodForeignKeyTrackingNotSupported + => GetString("PeriodForeignKeyTrackingNotSupported"); + /// /// PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires PostgreSQL 18.0 or later. If you're targeting an older version, remove the `WithPeriod()` configuration call. Otherwise, set PostgreSQL compatibility mode by calling 'optionsBuilder.UseNpgsql(..., o => o.SetPostgresVersion(18, 0))' in your model's OnConfiguring. /// diff --git a/src/EFCore.PG/Properties/NpgsqlStrings.resx b/src/EFCore.PG/Properties/NpgsqlStrings.resx index cce1d432fa..a60f6b1a26 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.resx +++ b/src/EFCore.PG/Properties/NpgsqlStrings.resx @@ -250,6 +250,9 @@ Using two distinct data sources within a service provider is not supported, and Entity Framework is not building its own internal service provider. Either allow Entity Framework to build the service provider by removing the call to '{useInternalServiceProvider}', or ensure that the same data source is used for all uses of a given service provider passed to '{useInternalServiceProvider}'. + + Queries that join entities via a PERIOD foreign key (temporal constraint) cannot use change tracking. Use 'AsNoTracking()' to execute this query. + PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires PostgreSQL 18.0 or later. If you're targeting an older version, remove the `WithPeriod()` configuration call. Otherwise, set PostgreSQL compatibility mode by calling 'optionsBuilder.UseNpgsql(..., o => o.SetPostgresVersion(18, 0))' in your model's OnConfiguring. diff --git a/src/EFCore.PG/Query/Internal/NpgsqlPeriodForeignKeyPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlPeriodForeignKeyPostprocessor.cs new file mode 100644 index 0000000000..826b67e266 --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlPeriodForeignKeyPostprocessor.cs @@ -0,0 +1,146 @@ +using System.Diagnostics.CodeAnalysis; +using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +/// +/// A postprocessor that rewrites join predicates for PERIOD foreign keys. For PERIOD FKs, the range column join +/// condition must use PostgreSQL range containment (@>) rather than equality (=), since the principal's +/// range must contain the dependent's range. +/// +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class NpgsqlPeriodForeignKeyPostprocessor(QueryTrackingBehavior queryTrackingBehavior) : ExpressionVisitor +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitExtension(Expression extensionExpression) + => extensionExpression switch + { + ShapedQueryExpression shapedQueryExpression + => shapedQueryExpression.Update( + Visit(shapedQueryExpression.QueryExpression), + Visit(shapedQueryExpression.ShaperExpression)), + + // For equality predicates between columns, check if they correspond to a PERIOD FK range column + // and replace with range containment (@>). + // Note that EF's change tracker assumes equality, but the temporal foreign key (PERIOD) uses containment; + // the change tracker is therefore currently incompatible with it. We check and throw, directing the user to use + // a no-tracking query instead. + SqlBinaryExpression { OperatorType: ExpressionType.Equal } eqExpression + when eqExpression.Left is ColumnExpression leftCol + && eqExpression.Right is ColumnExpression rightCol + && TryGetPeriodFkInfo(leftCol, rightCol, out var principalColumn, out var dependentColumn) + => queryTrackingBehavior is QueryTrackingBehavior.TrackAll + ? throw new InvalidOperationException(NpgsqlStrings.PeriodForeignKeyTrackingNotSupported) + : new PgBinaryExpression( + PgExpressionType.Contains, + principalColumn, + dependentColumn, + typeof(bool), + eqExpression.TypeMapping), + + _ => base.VisitExtension(extensionExpression) + }; + + /// + /// Determines whether two columns in an equality predicate correspond to the range column of a PERIOD FK, + /// and if so, identifies which is the principal column and which is the dependent column. + /// + private static bool TryGetPeriodFkInfo( + ColumnExpression leftCol, + ColumnExpression rightCol, + [NotNullWhen(true)] out ColumnExpression? principalColumn, + [NotNullWhen(true)] out ColumnExpression? dependentColumn) + { + principalColumn = null; + dependentColumn = null; + + // We need column metadata to identify the FK + if (leftCol.Column is not { } leftColumnBase || rightCol.Column is not { } rightColumnBase) + { + return false; + } + + // Check all properties mapped to the left column for PERIOD FK participation. + // We check both GetContainingForeignKeys() (property is on the dependent/FK side) and + // GetContainingKeys() -> GetReferencingForeignKeys() (property is on the principal/PK side). + foreach (var leftMapping in leftColumnBase.PropertyMappings) + { + var leftProperty = leftMapping.Property; + + foreach (var fk in GetPeriodForeignKeys(leftProperty)) + { + // The range property is the last one in the FK + var fkRangeProperty = fk.Properties[^1]; + var principalRangeProperty = fk.PrincipalKey.Properties[^1]; + + // Determine if the left column is the dependent or principal range property, + // and look for the counterpart on the right column. + IProperty expectedRight; + ColumnExpression candidatePrincipal, candidateDependent; + + if (leftProperty == fkRangeProperty) + { + expectedRight = principalRangeProperty; + candidatePrincipal = rightCol; + candidateDependent = leftCol; + } + else if (leftProperty == principalRangeProperty) + { + expectedRight = fkRangeProperty; + candidatePrincipal = leftCol; + candidateDependent = rightCol; + } + else + { + continue; + } + + foreach (var rightMapping in rightColumnBase.PropertyMappings) + { + if (rightMapping.Property == expectedRight) + { + principalColumn = candidatePrincipal; + dependentColumn = candidateDependent; + return true; + } + } + } + } + + return false; + + static IEnumerable GetPeriodForeignKeys(IProperty property) + { + foreach (var fk in property.GetContainingForeignKeys()) + { + if (fk.GetPeriod() == true) + { + yield return fk; + } + } + + foreach (var key in property.GetContainingKeys()) + { + foreach (var fk in key.GetReferencingForeignKeys()) + { + if (fk.GetPeriod() == true) + { + yield return fk; + } + } + } + } + } +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs index 0bb5e96d65..af6ebe6474 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs @@ -6,24 +6,14 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// -public class NpgsqlQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor +public class NpgsqlQueryTranslationPostprocessor( + QueryTranslationPostprocessorDependencies dependencies, + RelationalQueryTranslationPostprocessorDependencies relationalDependencies, + RelationalQueryCompilationContext queryCompilationContext) + : RelationalQueryTranslationPostprocessor(dependencies, relationalDependencies, queryCompilationContext) { private readonly NpgsqlSqlTreePruner _pruner = new(); - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - public NpgsqlQueryTranslationPostprocessor( - QueryTranslationPostprocessorDependencies dependencies, - RelationalQueryTranslationPostprocessorDependencies relationalDependencies, - RelationalQueryCompilationContext queryCompilationContext) - : base(dependencies, relationalDependencies, queryCompilationContext) - { - } - /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -36,6 +26,7 @@ public override Expression Process(Expression query) result = new NpgsqlUnnestPostprocessor().Visit(result); result = new NpgsqlSetOperationTypingInjector().Visit(result); + result = new NpgsqlPeriodForeignKeyPostprocessor(RelationalQueryCompilationContext.QueryTrackingBehavior).Visit(result); return result; } diff --git a/test/EFCore.PG.FunctionalTests/SpatialNpgsqlFixture.cs b/test/EFCore.PG.FunctionalTests/SpatialNpgsqlFixture.cs index 8b255aa533..699d165816 100644 --- a/test/EFCore.PG.FunctionalTests/SpatialNpgsqlFixture.cs +++ b/test/EFCore.PG.FunctionalTests/SpatialNpgsqlFixture.cs @@ -16,6 +16,7 @@ protected override IServiceCollection AddServices(IServiceCollection serviceColl public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) { var optionsBuilder = base.AddOptions(builder); + new NpgsqlDbContextOptionsBuilder(optionsBuilder) .UseNetTopologySuite() .SetPostgresVersion(TestEnvironment.PostgresVersion); diff --git a/test/EFCore.PG.FunctionalTests/TemporalConstraintTest.cs b/test/EFCore.PG.FunctionalTests/TemporalConstraintTest.cs new file mode 100644 index 0000000000..12a751d58d --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/TemporalConstraintTest.cs @@ -0,0 +1,459 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; + +namespace Microsoft.EntityFrameworkCore; + +[MinimumPostgresVersion(18, 0)] +public class TemporalConstraintTest : IClassFixture +{ + private TemporalConstraintFixture Fixture { get; } + + public TemporalConstraintTest(TemporalConstraintFixture fixture, ITestOutputHelper testOutputHelper) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + #region Insert + + [ConditionalFact] + public Task Can_insert_and_roundtrip_room_with_without_overlaps_primary_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + context.Rooms.Add( + new Room + { + Id = 100, + Validity = new NpgsqlRange( + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 12, 31, 0, 0, 0, DateTimeKind.Utc)), + Name = "New Room" + }); + + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var loaded = await context.Rooms.SingleAsync(r => r.Id == 100); + Assert.Equal("New Room", loaded.Name); + Assert.Equal( + new NpgsqlRange( + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 12, 31, 0, 0, 0, DateTimeKind.Utc)), + loaded.Validity); + }); + + [ConditionalFact] + public Task Can_insert_reservation_with_period_foreign_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + context.Reservations.Add( + new Reservation + { + RoomId = 1, + Validity = new NpgsqlRange( + new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 15, 0, 0, 0, DateTimeKind.Utc)), + Description = "New reservation" + }); + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var reservation = await context.Reservations.SingleAsync(r => r.Description == "New reservation"); + Assert.Equal(1, reservation.RoomId); + }); + + #endregion + + #region Query + + [ConditionalFact] + public async Task Range_contains_timestamp() + { + using var context = CreateContext(); + + // Find rooms valid at a specific point in time + var pointInTime = new DateTime(2025, 3, 15, 0, 0, 0, DateTimeKind.Utc); + var rooms = await context.Rooms + .Where(r => r.Validity.Contains(pointInTime)) + .OrderBy(r => r.Id) + .ToListAsync(); + + // Room 1 (full year) and Room 2 H1 (Jan-Jun) are valid in March + Assert.Equal(2, rooms.Count); + Assert.Equal("Conference Room A", rooms[0].Name); + Assert.Equal("Conference Room B (H1)", rooms[1].Name); + } + + [ConditionalFact] + public async Task Range_contains_range() + { + using var context = CreateContext(); + + // Find rooms whose validity fully contains the given range + var queryRange = new NpgsqlRange( + new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 5, 31, 0, 0, 0, DateTimeKind.Utc)); + var rooms = await context.Rooms + .Where(r => r.Validity.Contains(queryRange)) + .OrderBy(r => r.Id) + .ToListAsync(); + + // Only Room 1 (full year) fully contains Feb-May; Room 2 H1 (Jan-Jun) does as well + Assert.Equal(2, rooms.Count); + Assert.Equal("Conference Room A", rooms[0].Name); + Assert.Equal("Conference Room B (H1)", rooms[1].Name); + } + + [ConditionalFact] + public async Task Range_overlaps() + { + using var context = CreateContext(); + + // Find rooms whose validity overlaps with a range spanning both halves of the year + var queryRange = new NpgsqlRange( + new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 8, 1, 0, 0, 0, DateTimeKind.Utc)); + var rooms = await context.Rooms + .Where(r => r.Validity.Overlaps(queryRange)) + .OrderBy(r => r.Id) + .ThenBy(r => r.Validity) + .ToListAsync(); + + // All three room rows overlap with Jun-Aug + Assert.Equal(3, rooms.Count); + Assert.Equal("Conference Room A", rooms[0].Name); + Assert.Equal("Conference Room B (H1)", rooms[1].Name); + Assert.Equal("Conference Room B (H2)", rooms[2].Name); + } + + [ConditionalFact] + public async Task Range_contained_by() + { + using var context = CreateContext(); + + // Find reservations whose validity is contained within a given range + var queryRange = new NpgsqlRange( + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 31, 0, 0, 0, DateTimeKind.Utc)); + var reservations = await context.Reservations + .Where(r => r.Validity.ContainedBy(queryRange)) + .OrderBy(r => r.Id) + .ToListAsync(); + + // "Team meeting" (Feb) and "Workshop" (Mar) are within Jan-Mar; "Planning session" (Apr) is not + Assert.Equal(2, reservations.Count); + Assert.Equal("Team meeting", reservations[0].Description); + Assert.Equal("Workshop", reservations[1].Description); + } + + [ConditionalFact] + public async Task Range_is_strictly_left_of() + { + using var context = CreateContext(); + + // Find reservations whose validity is entirely before a given range + var queryRange = new NpgsqlRange( + new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)); + var reservations = await context.Reservations + .Where(r => r.Validity.IsStrictlyLeftOf(queryRange)) + .OrderBy(r => r.Id) + .ToListAsync(); + + // "Team meeting" (Feb) and "Workshop" (Mar) are entirely before Apr + Assert.Equal(2, reservations.Count); + Assert.Equal("Team meeting", reservations[0].Description); + Assert.Equal("Workshop", reservations[1].Description); + } + + [ConditionalFact] + public async Task Navigate_through_period_foreign_key() + { + using var context = CreateContext(); + + // Navigate from reservation to room via the PERIOD FK and project temporal columns + var result = await context.Reservations + .AsNoTracking() + .OrderBy(r => r.Id) + .Select(r => new { r.Description, RoomName = r.Room!.Name, RoomValidity = r.Room.Validity }) + .FirstAsync(); + + Assert.Equal("Team meeting", result.Description); + Assert.Equal("Conference Room A", result.RoomName); + Assert.Equal( + new NpgsqlRange( + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)), + result.RoomValidity); + } + + [ConditionalFact] + public void Period_foreign_key_with_tracking_throws() + { + using var context = CreateContext(); + + var exception = Assert.Throws( + () => context.Reservations + .OrderBy(r => r.Id) + .Select(r => new { r.Description, RoomName = r.Room!.Name }) + .First()); + + Assert.Equal(NpgsqlStrings.PeriodForeignKeyTrackingNotSupported, exception.Message); + } + + [ConditionalFact] + public async Task Include_collection_with_range_filter() + { + using var context = CreateContext(); + + // Load rooms valid at a specific timestamp, including their reservations + var pointInTime = new DateTime(2025, 9, 1, 0, 0, 0, DateTimeKind.Utc); + var rooms = await context.Rooms + .AsNoTracking() + .Include(r => r.Reservations) + .Where(r => r.Validity.Contains(pointInTime)) + .OrderBy(r => r.Id) + .ToListAsync(); + Assert.Equal("Conference Room A", rooms[0].Name); + Assert.Equal(2, rooms[0].Reservations.Count); + Assert.Equal("Conference Room B (H2)", rooms[1].Name); + Assert.Empty(rooms[1].Reservations); + } + + #endregion + + #region Update + + [ConditionalFact] + public Task Can_update_entity_with_without_overlaps_primary_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + var room = await context.Rooms.SingleAsync(r => r.Id == 1); + room.Name = "Updated Room"; + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var loaded = await context.Rooms.SingleAsync(r => r.Id == 1); + Assert.Equal("Updated Room", loaded.Name); + }); + + [ConditionalFact] + public Task Can_update_entity_with_period_foreign_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + var reservation = await context.Reservations.OrderBy(r => r.Id).FirstAsync(); + reservation.Description = "Updated description"; + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var loaded = await context.Reservations.OrderBy(r => r.Id).FirstAsync(); + Assert.Equal("Updated description", loaded.Description); + }); + + #endregion + + #region Delete + + [ConditionalFact] + public Task Can_delete_entity_with_without_overlaps_primary_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + // Room 2 H2 has no reservations, so it can be deleted + var room = await context.Rooms.SingleAsync( + r => r.Id == 2 + && r.Validity == new NpgsqlRange( + new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc))); + context.Rooms.Remove(room); + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var remaining = await context.Rooms.Where(r => r.Id == 2).ToListAsync(); + Assert.Single(remaining); + }); + + [ConditionalFact] + public Task Can_delete_entity_with_period_foreign_key() + => ExecuteWithStrategyInTransactionAsync( + async context => + { + var reservation = await context.Reservations.OrderBy(r => r.Id).FirstAsync(); + context.Reservations.Remove(reservation); + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var remaining = await context.Reservations.ToListAsync(); + Assert.Equal(2, remaining.Count); + }); + + #endregion + + private TemporalConstraintContext CreateContext() + => Fixture.CreateContext(); + + private Task ExecuteWithStrategyInTransactionAsync( + Func testOperation) + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, UseTransaction, testOperation); + + private static void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction) + => facade.UseTransaction(transaction.GetDbTransaction()); + + public class TemporalConstraintContext(DbContextOptions options) : PoolableDbContext(options) + { + public DbSet Rooms => Set(); + public DbSet Reservations => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.HasKey(r => new { r.Id, r.Validity }).WithoutOverlaps(); + + b.HasAlternateKey(r => new { r.RoomNumber, r.Validity }).WithoutOverlaps(); + + b.Property(r => r.Name).HasMaxLength(200); + }); + + modelBuilder.Entity(b => + { + b.HasKey(r => r.Id); + + b.HasOne(r => r.Room) + .WithMany(r => r.Reservations) + .HasForeignKey(r => new { r.RoomId, r.Validity }) + .HasPrincipalKey(r => new { r.Id, r.Validity }) + .WithPeriod(); + + b.Property(r => r.Description).HasMaxLength(500); + }); + } + } + + // ReSharper disable once MemberCanBePrivate.Global + public class Room + { + public int Id { get; set; } + public NpgsqlRange Validity { get; set; } + public int RoomNumber { get; set; } + public string Name { get; set; } = null!; + public List Reservations { get; set; } = []; + } + + // ReSharper disable once MemberCanBePrivate.Global + public class Reservation + { + public int Id { get; set; } + public int RoomId { get; set; } + public NpgsqlRange Validity { get; set; } + public string Description { get; set; } = null!; + public Room? Room { get; set; } + } + + public class TemporalConstraintFixture : SharedStoreFixtureBase + { + protected override string StoreName + => "TemporalConstraintTest"; + + protected override ITestStoreFactory TestStoreFactory + => NpgsqlTestStoreFactory.Instance; + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + { + var optionsBuilder = base.AddOptions(builder); + + // Note that we have [MinimumPostgresVersion(18, 0)] on the whole class + new NpgsqlDbContextOptionsBuilder(optionsBuilder) + .SetPostgresVersion(18, 0); + + return optionsBuilder; + } + + protected override async Task SeedAsync(TemporalConstraintContext context) + { + // Room 1: single validity period, has reservations + context.Rooms.Add( + new Room + { + Id = 1, + RoomNumber = 101, + Validity = new NpgsqlRange( + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)), + Name = "Conference Room A" + }); + + // Room 2: same Id, two non-overlapping validity periods (temporal versioning) + context.Rooms.Add( + new Room + { + Id = 2, + RoomNumber = 102, + Validity = new NpgsqlRange( + new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 6, 30, 0, 0, 0, DateTimeKind.Utc)), + Name = "Conference Room B (H1)" + }); + context.Rooms.Add( + new Room + { + Id = 2, + RoomNumber = 103, + Validity = new NpgsqlRange( + new DateTime(2025, 7, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)), + Name = "Conference Room B (H2)" + }); + + await context.SaveChangesAsync(); + + // Reservations referencing Room 1 (PERIOD FK - validity must be contained in room's validity) + context.Reservations.Add( + new Reservation + { + RoomId = 1, + Validity = new NpgsqlRange( + new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 2, 28, 0, 0, 0, DateTimeKind.Utc)), + Description = "Team meeting" + }); + context.Reservations.Add( + new Reservation + { + RoomId = 1, + Validity = new NpgsqlRange( + new DateTime(2025, 4, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 4, 30, 0, 0, 0, DateTimeKind.Utc)), + Description = "Planning session" + }); + + // Reservation referencing Room 2 H1 (PERIOD FK) + context.Reservations.Add( + new Reservation + { + RoomId = 2, + Validity = new NpgsqlRange( + new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2025, 3, 31, 0, 0, 0, DateTimeKind.Utc)), + Description = "Workshop" + }); + + await context.SaveChangesAsync(); + } + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + } +} diff --git a/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs index 252bebaded..63b33af95f 100644 --- a/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs +++ b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs @@ -48,10 +48,7 @@ public void Throws_for_WithoutOverlaps_on_primary_key_below_postgres_18() var modelBuilder = CreateConventionModelBuilder( o => o.UseNpgsql("Host=localhost", npgsqlOptions => npgsqlOptions.SetPostgresVersion(17, 0))); - // Use int for Period so EF Core's base validation (IComparable check) doesn't run first modelBuilder.Entity(b => b.HasKey(e => new { e.Id, e.Period }).WithoutOverlaps()); - - // Our version check happens before the range type check VerifyError( "WITHOUT OVERLAPS on primary key in entity type 'EntityWithIntPeriod' requires PostgreSQL 18.0 or later.", modelBuilder); @@ -64,7 +61,6 @@ public void Throws_for_Period_on_foreign_key_without_without_overlaps_on_princip var modelBuilder = CreateConventionModelBuilder( o => o.UseNpgsql("Host=localhost", npgsqlOptions => npgsqlOptions.SetPostgresVersion(18, 0))); - // Use int for Period to avoid EF Core's base validation issues with NpgsqlRange // Principal key does NOT have WithoutOverlaps configured modelBuilder.Entity( b =>