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 =>