diff --git a/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs new file mode 100644 index 000000000..40c961c3a --- /dev/null +++ b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs @@ -0,0 +1,191 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore; + +/// +/// Npgsql specific extension methods for relationship builders. +/// +public static class NpgsqlForeignKeyBuilderExtensions +{ + #region Period + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceCollectionBuilder WithPeriod( + this ReferenceCollectionBuilder referenceCollectionBuilder, + bool withPeriod = true) + { + Check.NotNull(referenceCollectionBuilder, nameof(referenceCollectionBuilder)); + + referenceCollectionBuilder.Metadata.SetPeriod(withPeriod); + + return referenceCollectionBuilder; + } + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The principal entity type in this relationship. + /// The dependent entity type in this relationship. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceCollectionBuilder WithPeriod( + this ReferenceCollectionBuilder referenceCollectionBuilder, + bool withPeriod = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceCollectionBuilder)WithPeriod( + (ReferenceCollectionBuilder)referenceCollectionBuilder, withPeriod); + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceReferenceBuilder WithPeriod( + this ReferenceReferenceBuilder referenceReferenceBuilder, + bool withPeriod = true) + { + Check.NotNull(referenceReferenceBuilder, nameof(referenceReferenceBuilder)); + + referenceReferenceBuilder.Metadata.SetPeriod(withPeriod); + + return referenceReferenceBuilder; + } + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The entity type on one end of the relationship. + /// The entity type on the other end of the relationship. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceReferenceBuilder WithPeriod( + this ReferenceReferenceBuilder referenceReferenceBuilder, + bool withPeriod = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceReferenceBuilder)WithPeriod( + (ReferenceReferenceBuilder)referenceReferenceBuilder, withPeriod); + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The same builder instance so that multiple calls can be chained. + public static OwnershipBuilder WithPeriod( + this OwnershipBuilder ownershipBuilder, + bool withPeriod = true) + { + Check.NotNull(ownershipBuilder, nameof(ownershipBuilder)); + + ownershipBuilder.Metadata.SetPeriod(withPeriod); + + return ownershipBuilder; + } + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// The entity type on one end of the relationship. + /// The entity type on the other end of the relationship. + /// The same builder instance so that multiple calls can be chained. + public static OwnershipBuilder WithPeriod( + this OwnershipBuilder ownershipBuilder, + bool withPeriod = true) + where TEntity : class + where TDependentEntity : class + => (OwnershipBuilder)WithPeriod( + (OwnershipBuilder)ownershipBuilder, withPeriod); + + /// + /// Configures the foreign key to use the PostgreSQL PERIOD feature for temporal foreign keys. + /// The last column in the foreign key must be a PostgreSQL range type, and the referenced + /// principal key must have WITHOUT OVERLAPS configured. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionForeignKeyBuilder? WithPeriod( + this IConventionForeignKeyBuilder foreignKeyBuilder, + bool? withPeriod = true, + bool fromDataAnnotation = false) + { + if (foreignKeyBuilder.CanSetPeriod(withPeriod, fromDataAnnotation)) + { + foreignKeyBuilder.Metadata.SetPeriod(withPeriod, fromDataAnnotation); + + return foreignKeyBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether PERIOD can be configured. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether to use PERIOD. + /// Indicates whether the configuration was specified using a data annotation. + /// if the foreign key can be configured with PERIOD. + public static bool CanSetPeriod( + this IConventionForeignKeyBuilder foreignKeyBuilder, + bool? withPeriod = true, + bool fromDataAnnotation = false) + { + Check.NotNull(foreignKeyBuilder, nameof(foreignKeyBuilder)); + + return foreignKeyBuilder.CanSetAnnotation(NpgsqlAnnotationNames.Period, withPeriod, fromDataAnnotation); + } + + #endregion Period +} diff --git a/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs new file mode 100644 index 000000000..7a5140fe1 --- /dev/null +++ b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs @@ -0,0 +1,61 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore; + +/// +/// Extension methods for for Npgsql-specific metadata. +/// +public static class NpgsqlForeignKeyExtensions +{ + #region Period + + /// + /// Returns a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The foreign key. + /// if the foreign key uses PERIOD. + public static bool? GetPeriod(this IReadOnlyForeignKey foreignKey) + => (bool?)foreignKey[NpgsqlAnnotationNames.Period]; + + /// + /// Sets a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The foreign key. + /// The value to set. + public static void SetPeriod(this IMutableForeignKey foreignKey, bool? period) + => foreignKey.SetOrRemoveAnnotation(NpgsqlAnnotationNames.Period, period); + + /// + /// Sets a value indicating whether the foreign key uses the PostgreSQL PERIOD feature for temporal foreign keys. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The foreign key. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static bool? SetPeriod(this IConventionForeignKey foreignKey, bool? period, bool fromDataAnnotation = false) + { + foreignKey.SetOrRemoveAnnotation(NpgsqlAnnotationNames.Period, period, fromDataAnnotation); + + return period; + } + + /// + /// Returns the for whether the foreign key uses PERIOD. + /// + /// The foreign key. + /// The . + public static ConfigurationSource? GetPeriodConfigurationSource(this IConventionForeignKey foreignKey) + => foreignKey.FindAnnotation(NpgsqlAnnotationNames.Period)?.GetConfigurationSource(); + + #endregion Period +} diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs index 9cabc02ad..23791956c 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs @@ -45,6 +45,7 @@ public override void Validate(IModel model, IDiagnosticsLogger @@ -294,7 +295,7 @@ protected virtual void ValidateWithoutOverlaps(IModel model) private void ValidateWithoutOverlapsKey(IKey key) { - var keyName = key.IsPrimaryKey() ? "primary key" : $"alternate key {{{string.Join(", ", key.Properties.Select(p => p.Name))}}}"; + var keyName = key.IsPrimaryKey() ? "primary key" : $"alternate key {key.Properties.Format()}"; var entityType = key.DeclaringEntityType; // Check PostgreSQL version requirement @@ -305,7 +306,7 @@ private void ValidateWithoutOverlapsKey(IKey key) } // Check that the last property is a range type - var lastProperty = key.Properties.Last(); + var lastProperty = key.Properties[^1]; var typeMapping = lastProperty.FindTypeMapping(); if (typeMapping is not NpgsqlRangeTypeMapping) @@ -318,4 +319,65 @@ private void ValidateWithoutOverlapsKey(IKey key) lastProperty.ClrType.ShortDisplayName())); } } + + /// + /// 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 virtual void ValidatePeriod(IModel model) + { + foreach (var entityType in model.GetEntityTypes()) + { + foreach (var foreignKey in entityType.GetDeclaredForeignKeys()) + { + if (foreignKey.GetPeriod() == true) + { + ValidatePeriodForeignKey(foreignKey); + } + } + } + } + + private void ValidatePeriodForeignKey(IForeignKey foreignKey) + { + var entityType = foreignKey.DeclaringEntityType; + var fkName = foreignKey.Properties.Format(); + var principalKey = foreignKey.PrincipalKey; + var principalEntityType = principalKey.DeclaringEntityType; + + if (!_postgresVersion.AtLeast(18)) + { + throw new InvalidOperationException( + NpgsqlStrings.PeriodRequiresPostgres18(fkName, entityType.DisplayName())); + } + + // Check that the principal key has WITHOUT OVERLAPS (check this before range type) + if (principalKey.GetWithoutOverlaps() != true) + { + throw new InvalidOperationException( + NpgsqlStrings.PeriodRequiresWithoutOverlapsOnPrincipal( + fkName, + entityType.DisplayName(), + principalKey.IsPrimaryKey() + ? "primary key" + : $"alternate key {principalKey.Properties.Format()}", + principalEntityType.DisplayName())); + } + + // Check that the last property is a range type + var lastProperty = foreignKey.Properties[^1]; + var typeMapping = lastProperty.FindTypeMapping(); + + if (typeMapping is not NpgsqlRangeTypeMapping) + { + throw new InvalidOperationException( + NpgsqlStrings.PeriodRequiresRangeType( + fkName, + entityType.DisplayName(), + lastProperty.Name, + lastProperty.ClrType.ShortDisplayName())); + } + } } diff --git a/src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs b/src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs index 7bbdf9ed6..0370454b2 100644 --- a/src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs +++ b/src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs @@ -64,6 +64,9 @@ public override ConventionSet CreateConventionSet() ReplaceConvention(conventionSet.ForeignKeyRemovedConventions, valueGenerationConvention); + conventionSet.ForeignKeyAnnotationChangedConventions.Add( + new NpgsqlPeriodConvention(Dependencies, RelationalDependencies)); + var storeGenerationConvention = new NpgsqlStoreGenerationConvention(Dependencies, RelationalDependencies); ReplaceConvention(conventionSet.PropertyAnnotationChangedConventions, storeGenerationConvention); diff --git a/src/EFCore.PG/Metadata/Conventions/NpgsqlPeriodConvention.cs b/src/EFCore.PG/Metadata/Conventions/NpgsqlPeriodConvention.cs new file mode 100644 index 000000000..a01791b82 --- /dev/null +++ b/src/EFCore.PG/Metadata/Conventions/NpgsqlPeriodConvention.cs @@ -0,0 +1,39 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions; + +/// +/// A convention that sets the delete behavior to for foreign keys with PERIOD, +/// since PostgreSQL does not support cascading deletes for temporal foreign keys. +/// +/// Parameter object containing dependencies for this convention. +/// Parameter object containing relational dependencies for this convention. +public class NpgsqlPeriodConvention( + ProviderConventionSetBuilderDependencies dependencies, + RelationalConventionSetBuilderDependencies relationalDependencies) + : IForeignKeyAnnotationChangedConvention +{ + /// + /// Dependencies for this service. + /// + protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; } = dependencies; + + /// + /// Relational provider-specific dependencies for this service. + /// + protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; } = relationalDependencies; + + /// + public virtual void ProcessForeignKeyAnnotationChanged( + IConventionForeignKeyBuilder relationshipBuilder, + string name, + IConventionAnnotation? annotation, + IConventionAnnotation? oldAnnotation, + IConventionContext context) + { + if (name == NpgsqlAnnotationNames.Period && annotation?.Value is true) + { + relationshipBuilder.OnDelete(DeleteBehavior.NoAction); + } + } +} diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs index 32d1ffb86..42519abd5 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs @@ -144,6 +144,14 @@ public static class NpgsqlAnnotationNames /// public const string WithoutOverlaps = Prefix + "WithoutOverlaps"; + /// + /// 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 const string Period = Prefix + "Period"; + /// /// 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 diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs index f42deda95..f92bb721c 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs @@ -222,6 +222,28 @@ public override IEnumerable For(IUniqueConstraint constraint, bool } } + /// + /// 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 override IEnumerable For(IForeignKeyConstraint constraint, bool designTime) + { + if (!designTime) + { + yield break; + } + + // Model validation ensures that these facets are the same on all mapped foreign keys + var modelForeignKey = constraint.MappedForeignKeys.First(); + + if (modelForeignKey.GetPeriod() == true) + { + yield return new Annotation(NpgsqlAnnotationNames.Period, true); + } + } + /// /// 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 diff --git a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs index c4011dcdc..c5478e4cf 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -1635,6 +1635,70 @@ protected override void UniqueConstraint( IndexOptions(operation, model, builder); } + /// + protected override void ForeignKeyConstraint( + AddForeignKeyOperation operation, + IModel? model, + MigrationCommandListBuilder builder) + { + if (operation.Name != null) + { + builder + .Append("CONSTRAINT ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) + .Append(" "); + } + + var withPeriod = operation[NpgsqlAnnotationNames.Period] is true; + + builder.Append("FOREIGN KEY ("); + if (withPeriod) + { + builder + .Append(ColumnList(operation.Columns[..^1])) + .Append(", PERIOD ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Columns[^1])); + } + else + { + builder.Append(ColumnList(operation.Columns)); + } + + builder + .Append(") REFERENCES ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.PrincipalTable, operation.PrincipalSchema)); + + if (operation.PrincipalColumns != null) + { + builder.Append(" ("); + if (withPeriod) + { + builder + .Append(ColumnList(operation.PrincipalColumns[..^1])) + .Append(", PERIOD ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.PrincipalColumns[^1])); + } + else + { + builder.Append(ColumnList(operation.PrincipalColumns)); + } + + builder.Append(")"); + } + + if (operation.OnUpdate != ReferentialAction.NoAction) + { + builder.Append(" ON UPDATE "); + ForeignKeyAction(operation.OnUpdate, builder); + } + + if (operation.OnDelete != ReferentialAction.NoAction) + { + builder.Append(" ON DELETE "); + ForeignKeyAction(operation.OnDelete, builder); + } + } + #endregion Standard migrations #region Utilities diff --git a/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs b/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs index 581714698..230f25fe0 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs +++ b/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs @@ -183,6 +183,30 @@ public static string TwoDataSourcesInSameServiceProvider(object? useInternalServ public static string TransientExceptionDetected => GetString("TransientExceptionDetected"); + /// + /// 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. + /// + public static string PeriodRequiresPostgres18(object? foreignKeyName, object? entityType) + => string.Format( + GetString("PeriodRequiresPostgres18", nameof(foreignKeyName), nameof(entityType)), + foreignKeyName, entityType); + + /// + /// PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires the last column to be a PostgreSQL range type (e.g. daterange, tsrange, tstzrange), but property '{property}' has type '{propertyType}'. + /// + public static string PeriodRequiresRangeType(object? foreignKeyName, object? entityType, object? property, object? propertyType) + => string.Format( + GetString("PeriodRequiresRangeType", nameof(foreignKeyName), nameof(entityType), nameof(property), nameof(propertyType)), + foreignKeyName, entityType, property, propertyType); + + /// + /// PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires the referenced {principalKeyName} in entity type '{principalEntityType}' to be configured with WITHOUT OVERLAPS. + /// + public static string PeriodRequiresWithoutOverlapsOnPrincipal(object? foreignKeyName, object? entityType, object? principalKeyName, object? principalEntityType) + => string.Format( + GetString("PeriodRequiresWithoutOverlapsOnPrincipal", nameof(foreignKeyName), nameof(entityType), nameof(principalKeyName), nameof(principalEntityType)), + foreignKeyName, entityType, principalKeyName, principalEntityType); + /// /// WITHOUT OVERLAPS on {keyOrIndexName} in entity type '{entityType}' requires PostgreSQL 18.0 or later. If you're targeting an older version, remove the `WithoutOverlaps()` 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 87f970af6..cce1d432f 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.resx +++ b/src/EFCore.PG/Properties/NpgsqlStrings.resx @@ -1,17 +1,17 @@  - @@ -250,6 +250,15 @@ 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}'. + + 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. + + + PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires the last column to be a PostgreSQL range type (e.g. daterange, tsrange, tstzrange), but property '{property}' has type '{propertyType}'. + + + PERIOD on foreign key '{foreignKeyName}' in entity type '{entityType}' requires the referenced {principalKeyName} in entity type '{principalEntityType}' to be configured with WITHOUT OVERLAPS. + WITHOUT OVERLAPS on {keyOrIndexName} in entity type '{entityType}' requires PostgreSQL 18.0 or later. If you're targeting an older version, remove the `WithoutOverlaps()` 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/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs index 01cc11bfc..87c1533f5 100644 --- a/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs +++ b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs @@ -955,6 +955,12 @@ deptype IN ('e', 'x') foreignKey.PrincipalColumns.Add(foreignKeyPrincipalColumn); } + // PERIOD (PostgreSQL 18+) + if (foreignKeyRecord.GetFieldValue("conperiod")) + { + foreignKey[NpgsqlAnnotationNames.Period] = true; + } + table.ForeignKeys.Add(foreignKey); ForeignKeyEnd: ; } diff --git a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs index 146c28fe1..bff25bffe 100644 --- a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs @@ -2524,6 +2524,69 @@ await Test( """ALTER TABLE "Reservations" ADD CONSTRAINT "AK_Reservations_RoomId_During" UNIQUE ("RoomId", "During");"""); } + [ConditionalFact, MinimumPostgresVersion(18, 0)] + public virtual async Task Create_table_with_foreign_key_with_period() + { + await Test( + _ => { }, + builder => + { + builder.Entity( + "Rooms", e => + { + e.Property("Id"); + e.Property>("During"); + e.HasKey("Id", "During").WithoutOverlaps(); + }); + builder.Entity( + "Reservations", e => + { + e.Property("Id"); + e.Property("RoomId"); + e.Property>("During"); + e.HasKey("Id"); + e.HasOne("Rooms").WithMany().HasForeignKey("RoomId", "During") + .HasPrincipalKey("Id", "During") + .WithPeriod(); + }); + }, + model => + { + var table = model.Tables.Single(t => t.Name == "Reservations"); + var foreignKey = Assert.Single(table.ForeignKeys); + Assert.Equal(true, foreignKey[NpgsqlAnnotationNames.Period]); + }); + + AssertSql( + """CREATE EXTENSION IF NOT EXISTS btree_gist CASCADE;""", + // + """ +CREATE TABLE "Rooms" ( + "Id" integer NOT NULL, + "During" tstzrange NOT NULL, + CONSTRAINT "PK_Rooms" PRIMARY KEY ("Id", "During" WITHOUT OVERLAPS) +); +""", + // + """ +CREATE TABLE "Reservations" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "During" tstzrange NOT NULL, + "RoomId" integer NOT NULL, + CONSTRAINT "PK_Reservations" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Reservations_Rooms_RoomId_During" FOREIGN KEY ("RoomId", PERIOD "During") REFERENCES "Rooms" ("Id", PERIOD "During") +); +""", + // + """ +CREATE INDEX "IX_Reservations_RoomId_During" ON "Reservations" ("RoomId", "During"); +"""); + } + + // Note: We don't have Alter_foreign_key_add_period and Alter_foreign_key_remove_period because PostgreSQL requires foreign keys + // to use PERIOD when referencing a primary key with WITHOUT OVERLAPS. + // It's not possible to have a non-PERIOD FK pointing to a WITHOUT OVERLAPS PK, so these migrations aren't valid. + #endregion #region Sequence diff --git a/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs index 2ed9b0822..252bebade 100644 --- a/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs +++ b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs @@ -37,7 +37,7 @@ public void Throws_for_WithoutOverlaps_on_alternate_key_without_range_type() }); VerifyError( - "WITHOUT OVERLAPS on alternate key {Name, Period} in entity type 'EntityWithIntPeriod' requires the last column to be a PostgreSQL range type (e.g. daterange, tsrange, tstzrange), but property 'Period' has type 'int'.", + "WITHOUT OVERLAPS on alternate key {'Name', 'Period'} in entity type 'EntityWithIntPeriod' requires the last column to be a PostgreSQL range type (e.g. daterange, tsrange, tstzrange), but property 'Period' has type 'int'.", modelBuilder); } @@ -57,6 +57,37 @@ public void Throws_for_WithoutOverlaps_on_primary_key_below_postgres_18() modelBuilder); } + [ConditionalFact] + public void Throws_for_Period_on_foreign_key_without_without_overlaps_on_principal() + { + // Configure PG 18 so version check passes + 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 => + { + b.HasKey(e => new { e.Id, e.Period }); + }); + modelBuilder.Entity( + b => + { + b.HasKey(e => e.Id); + b.HasOne().WithMany() + .HasForeignKey(e => new { e.PrincipalId, e.Period }) + .HasPrincipalKey(e => new { e.Id, e.Period }) + .WithPeriod(); + }); + + // The PERIOD validation should fail because principal key doesn't have WITHOUT OVERLAPS + // Note: We would also fail the range type check, but the WITHOUT OVERLAPS check comes first + VerifyError( + "PERIOD on foreign key '{'PrincipalId', 'Period'}' in entity type 'DependentWithIntPeriod' requires the referenced primary key in entity type 'PrincipalWithIntPeriod' to be configured with WITHOUT OVERLAPS.", + modelBuilder); + } + private class EntityWithIntPeriod { public int Id { get; set; } @@ -64,6 +95,19 @@ private class EntityWithIntPeriod public int Period { get; set; } } + private class PrincipalWithIntPeriod + { + public int Id { get; set; } + public int Period { get; set; } + } + + private class DependentWithIntPeriod + { + public int Id { get; set; } + public int PrincipalId { get; set; } + public int Period { get; set; } + } + protected virtual TestHelpers.TestModelBuilder CreateConventionModelBuilder( Func configureContext = null) => NpgsqlTestHelpers.Instance.CreateConventionBuilder(configureContext: configureContext);