diff --git a/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs new file mode 100644 index 000000000..69eac600b --- /dev/null +++ b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlForeignKeyBuilderExtensions.cs @@ -0,0 +1,72 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore; + +/// +/// Npgsql specific extension methods for configuring foreign keys. +/// +public static class NpgsqlForeignKeyBuilderExtensions +{ + /// + /// Configure the matching strategy to be used with the foreign key. + /// + /// The builder for the foreign key being configured. + /// The defining the used matching strategy. + /// + /// + /// + public static ReferenceReferenceBuilder UsesMatchStrategy(this ReferenceReferenceBuilder builder, PostgresMatchStrategy matchStrategy) + { + Check.NotNull(builder, nameof(builder)); + Check.IsDefined(matchStrategy, nameof(matchStrategy)); + builder.Metadata.SetMatchStrategy(matchStrategy); + return builder; + } + + /// + /// Configure the matching strategy to be used with the foreign key. + /// + /// The builder for the foreign key being configured. + /// The defining the used matching strategy. + /// + /// + /// + public static ReferenceReferenceBuilder UsesMatchStrategy( + this ReferenceReferenceBuilder builder, + PostgresMatchStrategy matchStrategy) + where TEntity : class + where TRelatedEntity : class + => (ReferenceReferenceBuilder)UsesMatchStrategy((ReferenceReferenceBuilder)builder, matchStrategy); + + /// + /// Configure the matching strategy to be used with the foreign key. + /// + /// The builder for the foreign key being configured. + /// The defining the used matching strategy. + /// + /// + /// + public static ReferenceCollectionBuilder UsesMatchStrategy(this ReferenceCollectionBuilder builder, PostgresMatchStrategy matchStrategy) + { + Check.NotNull(builder, nameof(builder)); + Check.IsDefined(matchStrategy, nameof(matchStrategy)); + builder.Metadata.SetMatchStrategy(matchStrategy); + return builder; + } + + /// + /// Configure the matching strategy to be used with the foreign key. + /// + /// The builder for the foreign key being configured. + /// The defining the used matching strategy. + /// + /// + /// + public static ReferenceCollectionBuilder UsesMatchStrategy( + this ReferenceCollectionBuilder builder, + PostgresMatchStrategy matchStrategy) + where TEntity : class + where TRelatedEntity : class + => (ReferenceCollectionBuilder)UsesMatchStrategy((ReferenceCollectionBuilder)builder, matchStrategy); +} diff --git a/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs new file mode 100644 index 000000000..cccc93295 --- /dev/null +++ b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlForeignKeyExtensions.cs @@ -0,0 +1,30 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; +// ReSharper disable once CheckNamespace + +namespace Microsoft.EntityFrameworkCore; + +/// +/// Npgsql specific extension methods for . +/// +public static class NpgsqlForeignKeyExtensions +{ + /// + /// Sets the for a foreign key. + /// + /// the foreign key being configured. + /// the defining the used matching strategy. + /// + /// + /// + public static void SetMatchStrategy(this IMutableForeignKey foreignKey, PostgresMatchStrategy matchStrategy) + => foreignKey.SetOrRemoveAnnotation(NpgsqlAnnotationNames.MatchStrategy, matchStrategy); + + /// + /// Returns the assigned for the provided foreign key + /// + /// the foreign key + /// the if assigned, null otherwise + public static PostgresMatchStrategy? GetMatchStrategy(this IReadOnlyForeignKey foreignKey) + => (PostgresMatchStrategy?)foreignKey[NpgsqlAnnotationNames.MatchStrategy]; +} diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs index 0f2dd325e..eac083f2a 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs @@ -271,4 +271,12 @@ public static class NpgsqlAnnotationNames /// // Replaced by IsDescending in EF Core 7.0 public const string IndexSortOrder = Prefix + "IndexSortOrder"; + + /// + /// 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 MatchStrategy = Prefix + "MatchStrategy"; } diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs index a9493d6ae..6d6c9c14c 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(IForeignKeyConstraint foreignKey, bool designTime) + { + if (!designTime) + { + yield break; + } + + foreach (var item in foreignKey.MappedForeignKeys) + { + if (item.GetMatchStrategy() is { } match) + { + yield return new Annotation(NpgsqlAnnotationNames.MatchStrategy, match); + } + } + } + /// /// 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/PostgresMatchStrategy.cs b/src/EFCore.PG/Metadata/PostgresMatchStrategy.cs new file mode 100644 index 000000000..be2acec83 --- /dev/null +++ b/src/EFCore.PG/Metadata/PostgresMatchStrategy.cs @@ -0,0 +1,25 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +/// +/// Matching strategies for a foreign key. +/// +/// +/// +/// +public enum PostgresMatchStrategy +{ + /// + /// The default matching strategy, allows any foreign key column to be NULL. + /// + Simple = 0, + + /// + /// Currently not implemented in PostgreSQL. + /// + Partial = 1, + + /// + /// Requires the foreign key to either have all columns to be set or all columns to be NULL. + /// + Full = 2 +} diff --git a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs index 800941791..3b3ff2fe8 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Globalization; using System.Text; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; @@ -1447,6 +1448,53 @@ protected virtual void GenerateDropRange(PostgresRange rangeType, IModel? model, #endregion Range management + #region MatchingStrategy management + + /// + protected override void ForeignKeyConstraint(AddForeignKeyOperation operation, IModel? model, MigrationCommandListBuilder builder) + { + if (operation.Name != null) + { + builder.Append("CONSTRAINT ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)).Append(" "); + } + + builder.Append("FOREIGN KEY (").Append(ColumnList(operation.Columns)).Append(") REFERENCES ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.PrincipalTable, operation.PrincipalSchema)); + if (operation.PrincipalColumns != null) + { + builder.Append(" (").Append(ColumnList(operation.PrincipalColumns)).Append(")"); + } + + if (operation[NpgsqlAnnotationNames.MatchStrategy] is PostgresMatchStrategy matchStrategy) + { + builder.Append(" MATCH ") + .Append(TranslateMatchStrategy(matchStrategy)); + } + + if (operation.OnUpdate != 0) + { + builder.Append(" ON UPDATE "); + ForeignKeyAction(operation.OnUpdate, builder); + } + + if (operation.OnDelete != 0) + { + builder.Append(" ON DELETE "); + ForeignKeyAction(operation.OnDelete, builder); + } + } + + private static string TranslateMatchStrategy(PostgresMatchStrategy matchStrategy) + => matchStrategy switch + { + PostgresMatchStrategy.Simple => "SIMPLE", + PostgresMatchStrategy.Partial => "PARTIAL", + PostgresMatchStrategy.Full => "FULL", + _ => throw new InvalidEnumArgumentException(nameof(matchStrategy), (int)matchStrategy, typeof(PostgresMatchStrategy)) + }; + + #endregion MatchingStrategy management + /// protected override void Generate( DropIndexOperation operation, diff --git a/src/Shared/Check.cs b/src/Shared/Check.cs index 05c1e3dde..17082029d 100644 --- a/src/Shared/Check.cs +++ b/src/Shared/Check.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; @@ -127,4 +128,15 @@ public static void DebugAssert([DoesNotReturnIf(false)] bool condition, string m [DoesNotReturn] public static void DebugFail(string message) => throw new Exception($"Check.DebugFail failed: {message}"); + + public static void IsDefined( + T value, + [InvokerParameterName] string parameterName) + where T : struct, Enum + { + if (!Enum.IsDefined(value)) + { + throw new InvalidEnumArgumentException(parameterName, Convert.ToInt32(value), typeof(T)); + } + } } diff --git a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs index 82918f6b8..29db36f7c 100644 --- a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs @@ -1147,7 +1147,8 @@ await Test( e.Property("Y"); }), builder => builder.Entity("People").Property("Sum") - .HasComputedColumnSql(""" + .HasComputedColumnSql( + """ "X" + "Y" """, stored: true), builder => builder.Entity("People").Property("Sum"), @@ -1671,7 +1672,8 @@ await Test( "People", b => { b.Property("Name"); - b.Property("Name2").HasComputedColumnSql(""" + b.Property("Name2").HasComputedColumnSql( + """ "Name" """, stored: true); }), @@ -1681,7 +1683,8 @@ await Test( model => { var computedColumn = Assert.Single(Assert.Single(model.Tables).Columns, c => c.Name == "Name2"); - Assert.Equal(""" + Assert.Equal( + """ "Name" """, computedColumn.ComputedColumnSql); Assert.Equal(NonDefaultCollation, computedColumn.Collation); @@ -1938,7 +1941,8 @@ await Test( _ => { }, builder => builder.Entity("People").HasIndex("Name") .IncludeProperties("FirstName", "LastName") - .HasFilter(""" + .HasFilter( + """ "Name" IS NOT NULL """), model => @@ -2031,7 +2035,8 @@ await Test( builder => builder.Entity("People").HasIndex("Name") .IsUnique() .IncludeProperties("FirstName", "LastName") - .HasFilter(""" + .HasFilter( + """ "Name" IS NOT NULL """), model => @@ -2460,6 +2465,60 @@ public override async Task Drop_check_constraint() AssertSql("""ALTER TABLE "People" DROP CONSTRAINT "CK_People_Foo";"""); } + [Theory] + [InlineData(PostgresMatchStrategy.Simple, "SIMPLE", false)] + [InlineData(PostgresMatchStrategy.Partial, "PARTIAL", true)] + [InlineData(PostgresMatchStrategy.Full, "FULL", false)] + public async Task Add_foreign_key_with_match_strategy(PostgresMatchStrategy strategy, string matchValue, bool throws) + { + var runningTest = Test( + builder => + { + builder.Entity( + "Customers", delegate(EntityTypeBuilder e) + { + e.Property("Id"); + e.HasKey("Id"); + e.Property("AddressId"); + }); + builder.Entity( + "Orders", delegate(EntityTypeBuilder e) + { + e.Property("Id"); + e.Property("CustomerId"); + }); + }, + _ => { }, + builder => + { + builder.Entity("Orders") + .HasOne("Customers") + .WithMany() + .HasForeignKey("CustomerId") + .UsesMatchStrategy(strategy) + .HasConstraintName("FK_Foo"); + }, + asserter: null); + + if (throws) + { + await Assert.ThrowsAsync(() => runningTest); + } + else + { + await runningTest; + } + + AssertSql( + """ +CREATE INDEX "IX_Orders_CustomerId" ON "Orders" ("CustomerId"); +""", + // + $""" +ALTER TABLE "Orders" ADD CONSTRAINT "FK_Foo" FOREIGN KEY ("CustomerId") REFERENCES "Customers" ("Id") MATCH {matchValue} ON DELETE CASCADE; +"""); + } + #endregion #region Sequence @@ -2727,7 +2786,6 @@ SELECT setval( """); } - #endregion Data seeding public override async Task Add_required_primitve_collection_with_custom_default_value_sql_to_existing_table() @@ -3225,7 +3283,6 @@ public override async Task Add_required_primitve_collection_with_custom_converte AssertSql("""ALTER TABLE "Customers" ADD "Numbers" text NOT NULL DEFAULT 'some numbers';"""); } - protected override string NonDefaultCollation => "POSIX";