diff --git a/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs b/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs index 40d5f17df..d9143e0ef 100644 --- a/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs +++ b/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs @@ -117,6 +117,10 @@ private static readonly MethodInfo IndexAreNullsDistinctMethodInfo = typeof(NpgsqlIndexBuilderExtensions).GetRequiredRuntimeMethod( nameof(NpgsqlIndexBuilderExtensions.AreNullsDistinct), typeof(IndexBuilder), typeof(bool)); + private static readonly MethodInfo KeyWithoutOverlapsMethodInfo + = typeof(NpgsqlKeyBuilderExtensions).GetRequiredRuntimeMethod( + nameof(NpgsqlKeyBuilderExtensions.WithoutOverlaps), typeof(KeyBuilder), typeof(bool)); + #endregion MethodInfos /// @@ -295,6 +299,25 @@ public override IReadOnlyList GenerateFluentApiCalls( return null; } + /// + /// 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 MethodCallCodeFragment? GenerateFluentApi(IKey key, IAnnotation annotation) + { + Check.NotNull(key, nameof(key)); + Check.NotNull(annotation, nameof(annotation)); + + if (annotation.Name == NpgsqlAnnotationNames.WithoutOverlaps) + { + return new MethodCallCodeFragment(KeyWithoutOverlapsMethodInfo, annotation.Value); + } + + return null; + } + /// /// 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/Extensions/BuilderExtensions/NpgsqlKeyBuilderExtensions.cs b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlKeyBuilderExtensions.cs new file mode 100644 index 000000000..41042e3d4 --- /dev/null +++ b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlKeyBuilderExtensions.cs @@ -0,0 +1,89 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore; + +/// +/// Npgsql specific extension methods for . +/// +public static class NpgsqlKeyBuilderExtensions +{ + #region WithoutOverlaps + + /// + /// Configures the key to use the PostgreSQL WITHOUT OVERLAPS feature. + /// The last property in the key must be a PostgreSQL range type. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder for the key being configured. + /// A value indicating whether to use WITHOUT OVERLAPS. + /// A builder to further configure the key. + public static KeyBuilder WithoutOverlaps(this KeyBuilder keyBuilder, bool withoutOverlaps = true) + { + Check.NotNull(keyBuilder, nameof(keyBuilder)); + + keyBuilder.Metadata.SetWithoutOverlaps(withoutOverlaps); + + return keyBuilder; + } + + /// + /// Configures the key to use the PostgreSQL WITHOUT OVERLAPS feature. + /// The last property in the key must be a PostgreSQL range type. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder for the key being configured. + /// A value indicating whether to use WITHOUT OVERLAPS. + /// A builder to further configure the key. + public static KeyBuilder WithoutOverlaps(this KeyBuilder keyBuilder, bool withoutOverlaps = true) + => (KeyBuilder)WithoutOverlaps((KeyBuilder)keyBuilder, withoutOverlaps); + + /// + /// Configures the key to use the PostgreSQL WITHOUT OVERLAPS feature. + /// The last property in the key must be a PostgreSQL range type. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The builder for the key being configured. + /// A value indicating whether to use WITHOUT OVERLAPS. + /// Indicates whether the configuration was specified using a data annotation. + /// A builder to further configure the key. + public static IConventionKeyBuilder? WithoutOverlaps( + this IConventionKeyBuilder keyBuilder, + bool? withoutOverlaps = true, + bool fromDataAnnotation = false) + { + if (keyBuilder.CanSetWithoutOverlaps(withoutOverlaps, fromDataAnnotation)) + { + keyBuilder.Metadata.SetWithoutOverlaps(withoutOverlaps, fromDataAnnotation); + + return keyBuilder; + } + + return null; + } + + /// + /// Returns a value indicating whether WITHOUT OVERLAPS can be configured. + /// + /// The builder for the key being configured. + /// A value indicating whether to use WITHOUT OVERLAPS. + /// Indicates whether the configuration was specified using a data annotation. + /// if the key can be configured with WITHOUT OVERLAPS. + public static bool CanSetWithoutOverlaps( + this IConventionKeyBuilder keyBuilder, + bool? withoutOverlaps = true, + bool fromDataAnnotation = false) + { + Check.NotNull(keyBuilder, nameof(keyBuilder)); + + return keyBuilder.CanSetAnnotation(NpgsqlAnnotationNames.WithoutOverlaps, withoutOverlaps, fromDataAnnotation); + } + + #endregion WithoutOverlaps +} diff --git a/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlKeyExtensions.cs b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlKeyExtensions.cs new file mode 100644 index 000000000..9fbed40d4 --- /dev/null +++ b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlKeyExtensions.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 NpgsqlKeyExtensions +{ + #region WithoutOverlaps + + /// + /// Returns a value indicating whether the key uses the PostgreSQL WITHOUT OVERLAPS feature. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The key. + /// if the key uses WITHOUT OVERLAPS. + public static bool? GetWithoutOverlaps(this IReadOnlyKey key) + => (bool?)key[NpgsqlAnnotationNames.WithoutOverlaps]; + + /// + /// Sets a value indicating whether the key uses the PostgreSQL WITHOUT OVERLAPS feature. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The key. + /// The value to set. + public static void SetWithoutOverlaps(this IMutableKey key, bool? withoutOverlaps) + => key.SetOrRemoveAnnotation(NpgsqlAnnotationNames.WithoutOverlaps, withoutOverlaps); + + /// + /// Sets a value indicating whether the key uses the PostgreSQL WITHOUT OVERLAPS feature. + /// + /// + /// See https://www.postgresql.org/docs/current/sql-createtable.html for more information. + /// + /// The key. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static bool? SetWithoutOverlaps(this IConventionKey key, bool? withoutOverlaps, bool fromDataAnnotation = false) + { + key.SetOrRemoveAnnotation(NpgsqlAnnotationNames.WithoutOverlaps, withoutOverlaps, fromDataAnnotation); + + return withoutOverlaps; + } + + /// + /// Returns the for whether the key uses WITHOUT OVERLAPS. + /// + /// The key. + /// The . + public static ConfigurationSource? GetWithoutOverlapsConfigurationSource(this IConventionKey key) + => key.FindAnnotation(NpgsqlAnnotationNames.WithoutOverlaps)?.GetConfigurationSource(); + + #endregion WithoutOverlaps +} diff --git a/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs b/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs index 6942a4f1e..9cabc02ad 100644 --- a/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs +++ b/src/EFCore.PG/Infrastructure/Internal/NpgsqlModelValidator.cs @@ -1,5 +1,6 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; @@ -43,6 +44,7 @@ public override void Validate(IModel model, IDiagnosticsLogger @@ -268,4 +270,52 @@ protected override void ValidateCompatible( storeObject.DisplayName())); } } + + /// + /// 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 ValidateWithoutOverlaps(IModel model) + { + foreach (var entityType in model.GetEntityTypes()) + { + // Validate primary key and alternate keys + foreach (var key in entityType.GetDeclaredKeys()) + { + if (key.GetWithoutOverlaps() == true) + { + ValidateWithoutOverlapsKey(key); + } + } + } + } + + private void ValidateWithoutOverlapsKey(IKey key) + { + var keyName = key.IsPrimaryKey() ? "primary key" : $"alternate key {{{string.Join(", ", key.Properties.Select(p => p.Name))}}}"; + var entityType = key.DeclaringEntityType; + + // Check PostgreSQL version requirement + if (!_postgresVersion.AtLeast(18)) + { + throw new InvalidOperationException( + NpgsqlStrings.WithoutOverlapsRequiresPostgres18(keyName, entityType.DisplayName())); + } + + // Check that the last property is a range type + var lastProperty = key.Properties.Last(); + var typeMapping = lastProperty.FindTypeMapping(); + + if (typeMapping is not NpgsqlRangeTypeMapping) + { + throw new InvalidOperationException( + NpgsqlStrings.WithoutOverlapsRequiresRangeType( + keyName, + entityType.DisplayName(), + lastProperty.Name, + lastProperty.ClrType.ShortDisplayName())); + } + } } diff --git a/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs b/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs index 92777fe3a..4ef99f453 100644 --- a/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs +++ b/src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs @@ -30,11 +30,30 @@ public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, ProcessRowVersionProperty(property, typeMapping); } } + + DiscoverBtreeGistForWithoutOverlaps(entityType, modelBuilder); } SetupEnums(modelBuilder); } + /// + /// Discovers the btree_gist extension if any keys or indexes use WITHOUT OVERLAPS. + /// + protected virtual void DiscoverBtreeGistForWithoutOverlaps( + IConventionEntityType entityType, + IConventionModelBuilder modelBuilder) + { + foreach (var key in entityType.GetDeclaredKeys()) + { + if (key.GetWithoutOverlaps() == true) + { + modelBuilder.HasPostgresExtension("btree_gist"); + return; + } + } + } + /// /// Configures the model to create PostgreSQL enums based on the user's enum definitions in the context options. /// diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs index 0f2dd325e..32d1ffb86 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs @@ -136,6 +136,14 @@ public static class NpgsqlAnnotationNames /// public const string UnloggedTable = Prefix + "UnloggedTable"; + /// + /// 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 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 diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs index a9493d6ae..f42deda95 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs @@ -200,6 +200,28 @@ public override IEnumerable For(ITableIndex index, bool designTime) } } + /// + /// 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(IUniqueConstraint constraint, bool designTime) + { + if (!designTime) + { + yield break; + } + + // Model validation ensures that these facets are the same on all mapped keys + var modelKey = constraint.MappedKeys.First(); + + if (modelKey.GetWithoutOverlaps() == true) + { + yield return new Annotation(NpgsqlAnnotationNames.WithoutOverlaps, 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 6c74ef60a..c4011dcdc 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -1579,6 +1579,62 @@ protected override void Generate(CreateSequenceOperation operation, IModel? mode } } + /// + protected override void PrimaryKeyConstraint( + AddPrimaryKeyOperation operation, + IModel? model, + MigrationCommandListBuilder builder) + { + if (operation.Name != null) + { + builder + .Append("CONSTRAINT ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) + .Append(" "); + } + + builder + .Append("PRIMARY KEY (") + .Append(ColumnList(operation.Columns)); + + if (operation[NpgsqlAnnotationNames.WithoutOverlaps] is true) + { + builder.Append(" WITHOUT OVERLAPS"); + } + + builder.Append(")"); + + IndexOptions(operation, model, builder); + } + + /// + protected override void UniqueConstraint( + AddUniqueConstraintOperation operation, + IModel? model, + MigrationCommandListBuilder builder) + { + if (operation.Name != null) + { + builder + .Append("CONSTRAINT ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) + .Append(" "); + } + + builder + .Append("UNIQUE (") + .Append(ColumnList(operation.Columns)); + + if (operation[NpgsqlAnnotationNames.WithoutOverlaps] is true) + { + builder.Append(" WITHOUT OVERLAPS"); + } + + builder.Append(")"); + + IndexOptions(operation, model, 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 23e9455d7..581714698 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs +++ b/src/EFCore.PG/Properties/NpgsqlStrings.Designer.cs @@ -183,6 +183,22 @@ public static string TwoDataSourcesInSameServiceProvider(object? useInternalServ public static string TransientExceptionDetected => GetString("TransientExceptionDetected"); + /// + /// 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. + /// + public static string WithoutOverlapsRequiresPostgres18(object? keyOrIndexName, object? entityType) + => string.Format( + GetString("WithoutOverlapsRequiresPostgres18", nameof(keyOrIndexName), nameof(entityType)), + keyOrIndexName, entityType); + + /// + /// WITHOUT OVERLAPS on {keyOrIndexName} 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 WithoutOverlapsRequiresRangeType(object? keyOrIndexName, object? entityType, object? property, object? propertyType) + => string.Format( + GetString("WithoutOverlapsRequiresRangeType", nameof(keyOrIndexName), nameof(entityType), nameof(property), nameof(propertyType)), + keyOrIndexName, entityType, property, propertyType); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name)!; diff --git a/src/EFCore.PG/Properties/NpgsqlStrings.resx b/src/EFCore.PG/Properties/NpgsqlStrings.resx index d40cedf4e..87f970af6 100644 --- a/src/EFCore.PG/Properties/NpgsqlStrings.resx +++ b/src/EFCore.PG/Properties/NpgsqlStrings.resx @@ -250,4 +250,10 @@ 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}'. + + 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. + + + WITHOUT OVERLAPS on {keyOrIndexName} 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}'. + \ No newline at end of file diff --git a/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs index b305a8626..01cc11bfc 100644 --- a/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs +++ b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs @@ -810,6 +810,9 @@ private static void GetConstraints( out List constraintIndexes, IDiagnosticsLogger logger) { + // conperiod was added in PG18 for WITHOUT OVERLAPS support + var supportsConperiod = connection.PostgreSqlVersion >= new Version(18, 0); + var commandText = $""" SELECT ns.nspname, @@ -821,7 +824,8 @@ private static void GetConstraints( frnns.nspname AS fr_nspname, frncls.relname AS fr_relname, confkey, - confdeltype::text + confdeltype::text, + {(supportsConperiod ? "con.conperiod" : "false AS conperiod")} FROM pg_class AS cls JOIN pg_namespace AS ns ON ns.oid = cls.relnamespace JOIN pg_constraint as con ON con.conrelid = cls.oid @@ -882,6 +886,12 @@ deptype IN ('e', 'x') } } + // WITHOUT OVERLAPS (PostgreSQL 18+) + if (primaryKeyRecord.GetFieldValue("conperiod")) + { + primaryKey[NpgsqlAnnotationNames.WithoutOverlaps] = true; + } + table.PrimaryKey = primaryKey; PkEnd: ; } @@ -970,6 +980,12 @@ deptype IN ('e', 'x') uniqueConstraint.Columns.Add(constraintColumn); } + // WITHOUT OVERLAPS (PostgreSQL 18+) + if (record.GetFieldValue("conperiod")) + { + uniqueConstraint[NpgsqlAnnotationNames.WithoutOverlaps] = true; + } + table.UniqueConstraints.Add(uniqueConstraint); constraintIndexes.Add(record.GetValueOrDefault("conindid")); diff --git a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs index 46a2d6122..146c28fe1 100644 --- a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs @@ -2,6 +2,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Scaffolding.Internal; +using NpgsqlTypes; namespace Microsoft.EntityFrameworkCore.Migrations; @@ -2179,8 +2180,7 @@ await Test( """CREATE INDEX "IX_Blogs_Title_Description" ON "Blogs" USING GIN (to_tsvector('simple', "Title" || ' ' || coalesce("Description", '')));"""); } - [ConditionalFact] - [MinimumPostgresVersion(15, 0)] + [ConditionalFact, MinimumPostgresVersion(15, 0)] public virtual async Task Create_index_with_nulls_not_distinct() { await Test( @@ -2396,6 +2396,134 @@ public override async Task Drop_check_constraint() AssertSql("""ALTER TABLE "People" DROP CONSTRAINT "CK_People_Foo";"""); } + [ConditionalFact, MinimumPostgresVersion(18, 0)] + public virtual async Task Create_table_with_primary_key_without_overlaps() + { + await Test( + _ => { }, + builder => builder.Entity( + "Reservations", e => + { + e.Property("Id"); + e.Property>("During"); + e.HasKey("Id", "During").WithoutOverlaps(); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.NotNull(table.PrimaryKey); + Assert.Equal(true, table.PrimaryKey[NpgsqlAnnotationNames.WithoutOverlaps]); + }); + + AssertSql( + """CREATE EXTENSION IF NOT EXISTS btree_gist CASCADE;""", + // + """ +CREATE TABLE "Reservations" ( + "Id" integer NOT NULL, + "During" tstzrange NOT NULL, + CONSTRAINT "PK_Reservations" PRIMARY KEY ("Id", "During" WITHOUT OVERLAPS) +); +"""); + } + + [ConditionalFact, MinimumPostgresVersion(18, 0)] + public virtual async Task Create_table_with_alternate_key_without_overlaps() + { + await Test( + _ => { }, + builder => builder.Entity( + "Reservations", e => + { + e.Property("Id"); + e.Property("RoomId"); + e.Property>("During"); + e.HasKey("Id"); + e.HasAlternateKey("RoomId", "During").WithoutOverlaps(); + }), + model => + { + var table = Assert.Single(model.Tables); + var uniqueConstraint = Assert.Single(table.UniqueConstraints); + Assert.Equal(true, uniqueConstraint[NpgsqlAnnotationNames.WithoutOverlaps]); + }); + + AssertSql( + """CREATE EXTENSION IF NOT EXISTS btree_gist CASCADE;""", + // + """ +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 "AK_Reservations_RoomId_During" UNIQUE ("RoomId", "During" WITHOUT OVERLAPS) +); +"""); + } + + [ConditionalFact, MinimumPostgresVersion(18, 0)] + public virtual async Task Alter_alternative_key_add_without_overlaps() + { + await Test( + builder => builder.Entity( + "Reservations", e => + { + e.Property("Id"); + e.Property("RoomId"); + e.Property>("During"); + e.HasKey("Id"); + e.HasAlternateKey("RoomId", "During"); + }), + _ => { }, + builder => builder.Entity( + "Reservations", e => + { + e.HasAlternateKey("RoomId", "During").WithoutOverlaps(); + }), + model => + { + var table = Assert.Single(model.Tables); + var uniqueConstraint = Assert.Single(table.UniqueConstraints); + Assert.Equal(true, uniqueConstraint[NpgsqlAnnotationNames.WithoutOverlaps]); + }); + + AssertSql( + """ALTER TABLE "Reservations" DROP CONSTRAINT "AK_Reservations_RoomId_During";""", + // + """CREATE EXTENSION IF NOT EXISTS btree_gist CASCADE;""", + // + """ALTER TABLE "Reservations" ADD CONSTRAINT "AK_Reservations_RoomId_During" UNIQUE ("RoomId", "During" WITHOUT OVERLAPS);"""); + } + + [ConditionalFact, MinimumPostgresVersion(18, 0)] + public virtual async Task Alter_unique_constraint_remove_without_overlaps() + { + await Test( + builder => builder.Entity( + "Reservations", e => + { + e.Property("Id"); + e.Property("RoomId"); + e.Property>("During"); + e.HasKey("Id"); + e.HasAlternateKey("RoomId", "During").WithoutOverlaps(); + }), + _ => { }, + builder => builder.Entity("Reservations", e => e.HasAlternateKey("RoomId", "During").WithoutOverlaps(false)), + model => + { + var table = Assert.Single(model.Tables); + var uniqueConstraint = Assert.Single(table.UniqueConstraints); + Assert.Null(uniqueConstraint[NpgsqlAnnotationNames.WithoutOverlaps]); + }); + + AssertSql( + """ALTER TABLE "Reservations" DROP CONSTRAINT "AK_Reservations_RoomId_During";""", + // + """ALTER TABLE "Reservations" ADD CONSTRAINT "AK_Reservations_RoomId_During" UNIQUE ("RoomId", "During");"""); + } + #endregion #region Sequence @@ -2893,8 +3021,7 @@ CREATE COLLATION dummy (LOCALE = 'POSIX', """); } - [ConditionalFact] - [MinimumPostgresVersion(12, 0)] + [ConditionalFact, MinimumPostgresVersion(12, 0)] public virtual async Task Create_collation_non_deterministic() { await Test( diff --git a/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs new file mode 100644 index 000000000..2ed9b0822 --- /dev/null +++ b/test/EFCore.PG.Tests/Infrastructure/NpgsqlModelValidatorTest.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore.TestUtilities; +using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; +using NpgsqlTypes; + +// ReSharper disable InconsistentNaming +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; + +public class NpgsqlModelValidatorTest +{ + [Fact] + public void Throws_for_WithoutOverlaps_on_primary_key_without_range_type() + { + // Configure PG 18 so version check passes, but range type check fails + var modelBuilder = CreateConventionModelBuilder( + o => o.UseNpgsql("Host=localhost", npgsqlOptions => npgsqlOptions.SetPostgresVersion(18, 0))); + + modelBuilder.Entity(b => b.HasKey(e => new { e.Id, e.Period }).WithoutOverlaps()); + + VerifyError( + "WITHOUT OVERLAPS on primary key 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); + } + + [Fact] + public void Throws_for_WithoutOverlaps_on_alternate_key_without_range_type() + { + // Configure PG 18 so version check passes, but range type check fails + var modelBuilder = CreateConventionModelBuilder( + o => o.UseNpgsql("Host=localhost", npgsqlOptions => npgsqlOptions.SetPostgresVersion(18, 0))); + + modelBuilder.Entity( + b => + { + b.HasKey(e => e.Id); + b.HasAlternateKey(e => new { e.Name, e.Period }).WithoutOverlaps(); + }); + + 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'.", + modelBuilder); + } + + [Fact] + public void Throws_for_WithoutOverlaps_on_primary_key_below_postgres_18() + { + // Configure PG 17 to test version check + 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); + } + + private class EntityWithIntPeriod + { + public int Id { get; set; } + public string Name { get; set; } + public int Period { get; set; } + } + + protected virtual TestHelpers.TestModelBuilder CreateConventionModelBuilder( + Func configureContext = null) + => NpgsqlTestHelpers.Instance.CreateConventionBuilder(configureContext: configureContext); + + protected virtual void VerifyError(string expectedMessage, TestHelpers.TestModelBuilder modelBuilder) + { + var message = Assert.Throws(() => Validate(modelBuilder)).Message; + Assert.StartsWith(expectedMessage, message); + } + + protected virtual IModel Validate(TestHelpers.TestModelBuilder modelBuilder) + => modelBuilder.FinalizeModel(); +}