From 3414478857998d94a3387db6ef042ae0ba7d2f9b Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 28 Feb 2026 17:11:55 +0200 Subject: [PATCH] Allow excluding foreign key from migrations Closes #15854 --- .../Design/AnnotationCodeGenerator.cs | 7 + ...nalCSharpRuntimeAnnotationCodeGenerator.cs | 1 + .../RelationalForeignKeyBuilderExtensions.cs | 162 ++++++++++++++++++ .../RelationalForeignKeyExtensions.cs | 41 +++++ .../RelationalRuntimeModelConvention.cs | 1 + .../Metadata/IForeignKeyConstraint.cs | 5 + .../Metadata/Internal/ForeignKeyConstraint.cs | 9 + .../RelationalForeignKeyExtensions.cs | 17 ++ .../Metadata/RelationalAnnotationNames.cs | 6 + .../Internal/MigrationsModelDiffer.cs | 6 +- .../Properties/RelationalStrings.Designer.cs | 8 + .../Properties/RelationalStrings.resx | 3 + ...rpMigrationsGeneratorTest.ModelSnapshot.cs | 66 +++++++ .../Design/CSharpMigrationsGeneratorTest.cs | 6 +- .../Migrations/MigrationsTestBase.cs | 27 +++ .../Design/AnnotationCodeGeneratorTest.cs | 37 ++++ .../RelationalBuilderExtensionsTest.cs | 101 +++++++++++ .../RelationalMetadataExtensionsTest.cs | 31 ++++ .../RelationalModelValidatorTest.cs | 18 ++ .../Internal/MigrationsModelDifferTest.cs | 49 ++++++ .../Migrations/MigrationsSqlServerTest.cs | 10 ++ .../Migrations/MigrationsSqliteTest.cs | 10 ++ 22 files changed, 617 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs index 1762c7e9f1c..8f1abadd947 100644 --- a/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs @@ -435,6 +435,12 @@ public virtual IReadOnlyList GenerateFluentApiCalls( nameof(RelationalForeignKeyBuilderExtensions.HasConstraintName), methodCallCodeFragments); + GenerateSimpleFluentApiCall( + annotations, + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, + nameof(RelationalForeignKeyBuilderExtensions.ExcludeFromMigrations), + methodCallCodeFragments); + methodCallCodeFragments.AddRange(GenerateFluentApiCallsHelper(foreignKey, annotations, GenerateFluentApi)); return methodCallCodeFragments; @@ -1051,6 +1057,7 @@ private static void GenerateSimpleFluentApiCall( if (annotations.TryGetValue(annotationName, out var annotation)) { annotations.Remove(annotationName); + if (annotation.Value is { } annotationValue) { methodCallCodeFragments.Add( diff --git a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs index ed97fd13171..0f3435229c4 100644 --- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs @@ -2167,6 +2167,7 @@ public override void Generate(IForeignKey foreignKey, CSharpRuntimeAnnotationCod if (parameters.IsRuntime) { parameters.Annotations.Remove(RelationalAnnotationNames.ForeignKeyMappings); + parameters.Annotations.Remove(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations); } base.Generate(foreignKey, parameters); diff --git a/src/EFCore.Relational/Extensions/RelationalForeignKeyBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalForeignKeyBuilderExtensions.cs index 718cc713542..6b05406bd48 100644 --- a/src/EFCore.Relational/Extensions/RelationalForeignKeyBuilderExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalForeignKeyBuilderExtensions.cs @@ -173,4 +173,166 @@ public static bool CanSetConstraintName( string? name, bool fromDataAnnotation = false) => relationship.CanSetAnnotation(RelationalAnnotationNames.Name, name, fromDataAnnotation); + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceCollectionBuilder ExcludeFromMigrations( + this ReferenceCollectionBuilder referenceCollectionBuilder, + bool excluded = true) + { + referenceCollectionBuilder.Metadata.SetIsExcludedFromMigrations(excluded); + + return referenceCollectionBuilder; + } + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + /// The principal entity type in this relationship. + /// The dependent entity type in this relationship. + public static ReferenceCollectionBuilder ExcludeFromMigrations( + this ReferenceCollectionBuilder referenceCollectionBuilder, + bool excluded = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceCollectionBuilder)ExcludeFromMigrations( + (ReferenceCollectionBuilder)referenceCollectionBuilder, excluded); + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + public static ReferenceReferenceBuilder ExcludeFromMigrations( + this ReferenceReferenceBuilder referenceReferenceBuilder, + bool excluded = true) + { + referenceReferenceBuilder.Metadata.SetIsExcludedFromMigrations(excluded); + + return referenceReferenceBuilder; + } + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + /// The entity type on one end of the relationship. + /// The entity type on the other end of the relationship. + public static ReferenceReferenceBuilder ExcludeFromMigrations( + this ReferenceReferenceBuilder referenceReferenceBuilder, + bool excluded = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceReferenceBuilder)ExcludeFromMigrations( + (ReferenceReferenceBuilder)referenceReferenceBuilder, excluded); + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + public static OwnershipBuilder ExcludeFromMigrations( + this OwnershipBuilder ownershipBuilder, + bool excluded = true) + { + ownershipBuilder.Metadata.SetIsExcludedFromMigrations(excluded); + + return ownershipBuilder; + } + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// The same builder instance so that multiple calls can be chained. + /// The entity type on one end of the relationship. + /// The entity type on the other end of the relationship. + public static OwnershipBuilder ExcludeFromMigrations( + this OwnershipBuilder ownershipBuilder, + bool excluded = true) + where TEntity : class + where TDependentEntity : class + => (OwnershipBuilder)ExcludeFromMigrations( + (OwnershipBuilder)ownershipBuilder, excluded); + + /// + /// Configures whether the foreign key constraint is excluded from migrations + /// when targeting a relational database. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// Indicates whether the configuration was specified using a data annotation. + /// + /// The same builder instance if the configuration was applied, + /// otherwise. + /// + public static IConventionForeignKeyBuilder? ExcludeFromMigrations( + this IConventionForeignKeyBuilder relationship, + bool? excluded, + bool fromDataAnnotation = false) + { + if (!relationship.CanSetExcludeFromMigrations(excluded, fromDataAnnotation)) + { + return null; + } + + relationship.Metadata.SetIsExcludedFromMigrations(excluded, fromDataAnnotation); + return relationship; + } + + /// + /// Returns a value indicating whether the foreign key constraint exclusion from migrations can be set + /// from the current configuration source. + /// + /// + /// See Modeling entity types and relationships for more information and examples. + /// + /// The builder being used to configure the relationship. + /// A value indicating whether the foreign key constraint is excluded from migrations. + /// Indicates whether the configuration was specified using a data annotation. + /// if the configuration can be applied. + public static bool CanSetExcludeFromMigrations( + this IConventionForeignKeyBuilder relationship, + bool? excluded, + bool fromDataAnnotation = false) + => relationship.CanSetAnnotation(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, excluded, fromDataAnnotation); } diff --git a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs index 985ac838e1f..640804bfa23 100644 --- a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs @@ -288,4 +288,45 @@ static bool IsMapped(IReadOnlyForeignKey foreignKey, StoreObjectIdentifier store this IForeignKey foreignKey, in StoreObjectIdentifier storeObject) => (IForeignKey?)((IReadOnlyForeignKey)foreignKey).FindSharedObjectRootForeignKey(storeObject); + + /// + /// Returns a value indicating whether the foreign key constraint is excluded from migrations. + /// + /// The foreign key. + /// if the foreign key constraint is excluded from migrations. + public static bool IsExcludedFromMigrations(this IReadOnlyForeignKey foreignKey) + => (bool?)foreignKey[RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations] ?? false; + + /// + /// Sets a value indicating whether the foreign key constraint is excluded from migrations. + /// + /// The foreign key. + /// The value to set. + public static void SetIsExcludedFromMigrations(this IMutableForeignKey foreignKey, bool? excluded) + => foreignKey.SetOrRemoveAnnotation(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, excluded); + + /// + /// Sets a value indicating whether the foreign key constraint is excluded from migrations. + /// + /// The foreign key. + /// The value to set. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured value. + public static bool? SetIsExcludedFromMigrations( + this IConventionForeignKey foreignKey, + bool? excluded, + bool fromDataAnnotation = false) + => (bool?)foreignKey.SetOrRemoveAnnotation( + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, + excluded, + fromDataAnnotation)?.Value; + + /// + /// Gets the for the foreign key exclusion from migrations. + /// + /// The foreign key. + /// The for the foreign key exclusion from migrations. + public static ConfigurationSource? GetIsExcludedFromMigrationsConfigurationSource(this IConventionForeignKey foreignKey) + => foreignKey.FindAnnotation(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations) + ?.GetConfigurationSource(); } diff --git a/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs b/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs index 20aeb33cd38..5a31e791746 100644 --- a/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs @@ -505,6 +505,7 @@ protected override void ProcessForeignKeyAnnotations( if (runtime) { annotations.Remove(RelationalAnnotationNames.ForeignKeyMappings); + annotations.Remove(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations); } } diff --git a/src/EFCore.Relational/Metadata/IForeignKeyConstraint.cs b/src/EFCore.Relational/Metadata/IForeignKeyConstraint.cs index a89311c9b13..27e5b87e837 100644 --- a/src/EFCore.Relational/Metadata/IForeignKeyConstraint.cs +++ b/src/EFCore.Relational/Metadata/IForeignKeyConstraint.cs @@ -55,6 +55,11 @@ IReadOnlyList PrincipalColumns /// ReferentialAction OnDeleteAction { get; } + /// + /// Gets a value indicating whether the foreign key constraint is excluded from migrations. + /// + bool IsExcludedFromMigrations { get; } + /// /// /// Creates a human-readable representation of the given metadata. diff --git a/src/EFCore.Relational/Metadata/Internal/ForeignKeyConstraint.cs b/src/EFCore.Relational/Metadata/Internal/ForeignKeyConstraint.cs index 53d904470e6..e23cb6144b0 100644 --- a/src/EFCore.Relational/Metadata/Internal/ForeignKeyConstraint.cs +++ b/src/EFCore.Relational/Metadata/Internal/ForeignKeyConstraint.cs @@ -102,6 +102,15 @@ public override bool IsReadOnly /// public virtual ReferentialAction OnDeleteAction { get; set; } + /// + /// 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 virtual bool IsExcludedFromMigrations + => ((IReadOnlyForeignKey)MappedForeignKeys.First()).IsExcludedFromMigrations(); + /// /// 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.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs index 4c7460bb7dc..9e49913facb 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalForeignKeyExtensions.cs @@ -161,6 +161,23 @@ is not { } principalColumns return false; } + if (foreignKey.IsExcludedFromMigrations() != duplicateForeignKey.IsExcludedFromMigrations()) + { + if (shouldThrow) + { + throw new InvalidOperationException( + RelationalStrings.DuplicateForeignKeyExcludedFromMigrationsMismatch( + foreignKey.Properties.Format(), + foreignKey.DeclaringEntityType.DisplayName(), + duplicateForeignKey.Properties.Format(), + duplicateForeignKey.DeclaringEntityType.DisplayName(), + foreignKey.DeclaringEntityType.GetSchemaQualifiedTableName(), + foreignKey.GetConstraintName(storeObject, principalTable.Value))); + } + + return false; + } + return true; } diff --git a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs index 97084eee01a..029db49c5a6 100644 --- a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs +++ b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs @@ -174,6 +174,11 @@ public static class RelationalAnnotationNames /// public const string IsTableExcludedFromMigrations = Prefix + "IsTableExcludedFromMigrations"; + /// + /// The name for the annotation determining whether the foreign key constraint is excluded from migrations. + /// + public const string IsForeignKeyExcludedFromMigrations = Prefix + "IsForeignKeyExcludedFromMigrations"; + /// /// The name for the annotation determining the mapping strategy for inherited properties. /// @@ -396,6 +401,7 @@ public static class RelationalAnnotationNames IsFixedLength, ViewDefinitionSql, IsTableExcludedFromMigrations, + IsForeignKeyExcludedFromMigrations, MappingStrategy, RelationalModel, RelationalModelFactory, diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index 555ff93715b..d72ec524220 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1429,7 +1429,8 @@ protected virtual IEnumerable Diff( protected virtual IEnumerable Add(IForeignKeyConstraint target, DiffContext diffContext) { var targetTable = target.Table; - if (targetTable.IsExcludedFromMigrations) + if (targetTable.IsExcludedFromMigrations + || target.IsExcludedFromMigrations) { yield break; } @@ -1456,7 +1457,8 @@ protected virtual IEnumerable Add(IForeignKeyConstraint targ protected virtual IEnumerable Remove(IForeignKeyConstraint source, DiffContext diffContext) { var sourceTable = source.Table; - if (sourceTable.IsExcludedFromMigrations) + if (sourceTable.IsExcludedFromMigrations + || source.IsExcludedFromMigrations) { yield break; } diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 879e4b3c93f..681131294a1 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -571,6 +571,14 @@ public static string DuplicateForeignKeyDeleteBehaviorMismatch(object? foreignKe GetString("DuplicateForeignKeyDeleteBehaviorMismatch", nameof(foreignKeyProperties1), nameof(entityType1), nameof(foreignKeyProperties2), nameof(entityType2), nameof(table), nameof(foreignKeyName), nameof(deleteBehavior1), nameof(deleteBehavior2)), foreignKeyProperties1, entityType1, foreignKeyProperties2, entityType2, table, foreignKeyName, deleteBehavior1, deleteBehavior2); + /// + /// The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but with different migration exclusion configurations. + /// + public static string DuplicateForeignKeyExcludedFromMigrationsMismatch(object? foreignKeyProperties1, object? entityType1, object? foreignKeyProperties2, object? entityType2, object? table, object? foreignKeyName) + => string.Format( + GetString("DuplicateForeignKeyExcludedFromMigrationsMismatch", nameof(foreignKeyProperties1), nameof(entityType1), nameof(foreignKeyProperties2), nameof(entityType2), nameof(table), nameof(foreignKeyName)), + foreignKeyProperties1, entityType1, foreignKeyProperties2, entityType2, table, foreignKeyName); + /// /// The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but referencing different principal columns ({principalColumnNames1} and {principalColumnNames2}). /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 1dbddd15833..85b1642ceeb 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -328,6 +328,9 @@ The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but configured with different delete behavior ('{deleteBehavior1}' and '{deleteBehavior2}'). + + The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but with different migration exclusion configurations. + The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but referencing different principal columns ({principalColumnNames1} and {principalColumnNames2}). diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs index 13e0cf78c93..469d99cf3e7 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs @@ -8017,6 +8017,72 @@ public virtual void ForeignKey_constraint_name_is_stored_in_snapshot_as_fluent_a o => Assert.Equal( "Constraint", o.FindEntityType(typeof(EntityWithTwoProperties)).GetForeignKeys().First()["Relational:Name"])); + [ConditionalFact] + public virtual void ForeignKey_excluded_from_migrations_is_stored_in_snapshot() + => Test( + builder => + { + builder.Entity() + .HasOne(e => e.EntityWithOneProperty) + .WithOne(e => e.EntityWithTwoProperties) + .HasForeignKey(e => e.AlternateId) + .ExcludeFromMigrations(); + }, + AddBoilerPlate( + GetHeading() + + """ + modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithOneProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.HasKey("Id"); + + b.ToTable("EntityWithOneProperty", "DefaultSchema"); + }); + + modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithTwoProperties", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlternateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AlternateId") + .IsUnique(); + + b.ToTable("EntityWithTwoProperties", "DefaultSchema"); + }); + + modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithTwoProperties", b => + { + b.HasOne("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithOneProperty", "EntityWithOneProperty") + .WithOne("EntityWithTwoProperties") + .HasForeignKey("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithTwoProperties", "AlternateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .ExcludeFromMigrations(true); + + b.Navigation("EntityWithOneProperty"); + }); + + modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+EntityWithOneProperty", b => + { + b.Navigation("EntityWithTwoProperties"); + }); +"""), + o => Assert.True( + o.FindEntityType(typeof(EntityWithTwoProperties)).GetForeignKeys().First().IsExcludedFromMigrations())); + [ConditionalFact] public virtual void ForeignKey_multiple_annotations_are_stored_in_snapshot() => Test( diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs index 2625501a591..6c6d3c3e73c 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs @@ -102,7 +102,8 @@ public void Test_new_annotations_handled_for_entity_types() RelationalAnnotationNames.ContainerColumnTypeMapping, #pragma warning restore CS0618 RelationalAnnotationNames.StoreType, - RelationalAnnotationNames.UseNamedDefaultConstraints + RelationalAnnotationNames.UseNamedDefaultConstraints, + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations }; // Add a line here if the code generator is supposed to handle this annotation @@ -263,7 +264,8 @@ public void Test_new_annotations_handled_for_properties() #pragma warning restore CS0618 RelationalAnnotationNames.JsonPropertyName, RelationalAnnotationNames.StoreType, - RelationalAnnotationNames.UseNamedDefaultConstraints + RelationalAnnotationNames.UseNamedDefaultConstraints, + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations }; var columnMapping = $@"{_nl}.{nameof(RelationalPropertyBuilderExtensions.HasColumnType)}(""default_int_mapping"")"; diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index b5e8c8afcda..43a6e35d9aa 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -2328,6 +2328,33 @@ public virtual Task Add_foreign_key_with_name() } }); + [ConditionalFact] + public virtual Task Add_foreign_key_excluded_from_migrations() + => Test( + builder => + { + builder.Entity( + "Customers", e => + { + e.Property("Id"); + e.HasKey("Id"); + }); + builder.Entity( + "Orders", e => + { + e.Property("Id"); + e.Property("CustomerId"); + }); + }, + builder => { }, + builder => builder.Entity("Orders").HasOne("Customers").WithMany() + .HasForeignKey("CustomerId").ExcludeFromMigrations(), + model => + { + var ordersTable = Assert.Single(model.Tables, t => t.Name == "Orders"); + Assert.Empty(ordersTable.ForeignKeys); + }); + [ConditionalFact] public virtual Task Drop_foreign_key() => Test( diff --git a/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs b/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs index d390013a875..7ac8d15fc2b 100644 --- a/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs +++ b/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs @@ -44,6 +44,43 @@ public void GenerateFluentApi_IProperty_works_with_collation() Assert.Equal("foo", Assert.Single(result.Arguments)); } + [ConditionalFact] + public void IsForeignKeyExcludedFromMigrations_false_is_handled_by_convention() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity("Blog", x => + { + x.Property("Id"); + x.Property("ParentId"); + x.HasOne("Blog").WithMany().HasForeignKey("ParentId"); + }); + var foreignKey = modelBuilder.Model.FindEntityType("Blog").GetForeignKeys().Single(); + + var annotations = foreignKey.GetAnnotations().ToDictionary(a => a.Name, a => a); + CreateGenerator().RemoveAnnotationsHandledByConventions((IForeignKey)foreignKey, annotations); + + Assert.DoesNotContain(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, annotations.Keys); + } + + [ConditionalFact] + public void GenerateFluentApi_IForeignKey_works_with_ExcludeFromMigrations() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity("Blog", x => + { + x.Property("Id"); + x.Property("ParentId"); + x.HasOne("Blog").WithMany().HasForeignKey("ParentId").ExcludeFromMigrations(); + }); + var foreignKey = modelBuilder.Model.FindEntityType("Blog").GetForeignKeys().Single(); + + var annotations = foreignKey.GetAnnotations().ToDictionary(a => a.Name, a => a); + var result = CreateGenerator().GenerateFluentApiCalls((IForeignKey)foreignKey, annotations).Single(); + + Assert.Equal("ExcludeFromMigrations", result.Method); + Assert.Equal(true, Assert.Single(result.Arguments)); + } + private ModelBuilder CreateModelBuilder() => FakeRelationalTestHelpers.Instance.CreateConventionBuilder(); diff --git a/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs b/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs index df43bd34476..28497b96471 100644 --- a/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs +++ b/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs @@ -372,6 +372,69 @@ public void Can_set_foreign_key_name_for_one_to_one_with_FK_specified() Assert.Equal("LemonSupreme", foreignKey.GetConstraintName()); } + [ConditionalFact] + public void Can_set_foreign_key_exclude_from_migrations_for_one_to_many() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder + .Entity().HasMany(e => e.Orders).WithOne(e => e.Customer) + .ExcludeFromMigrations(); + + var foreignKey = modelBuilder.Model.FindEntityType(typeof(Order)).GetForeignKeys() + .Single(fk => fk.PrincipalEntityType.ClrType == typeof(Customer)); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + + modelBuilder + .Entity().HasMany(e => e.Orders).WithOne(e => e.Customer) + .ExcludeFromMigrations(false); + + Assert.False(foreignKey.IsExcludedFromMigrations()); + } + + [ConditionalFact] + public void Can_set_foreign_key_exclude_from_migrations_for_many_to_one() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder + .Entity().HasOne(e => e.Customer).WithMany(e => e.Orders) + .ExcludeFromMigrations(); + + var foreignKey = modelBuilder.Model.FindEntityType(typeof(Order)).GetForeignKeys() + .Single(fk => fk.PrincipalEntityType.ClrType == typeof(Customer)); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + + modelBuilder + .Entity().HasOne(e => e.Customer).WithMany(e => e.Orders) + .ExcludeFromMigrations(false); + + Assert.False(foreignKey.IsExcludedFromMigrations()); + } + + [ConditionalFact] + public void Can_set_foreign_key_exclude_from_migrations_for_one_to_one() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder + .Entity().HasOne(e => e.Details).WithOne(e => e.Order) + .HasPrincipalKey(e => e.OrderId) + .ExcludeFromMigrations(); + + var foreignKey = modelBuilder.Model.FindEntityType(typeof(OrderDetails)).GetForeignKeys().Single(); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + + modelBuilder + .Entity().HasOne(e => e.Details).WithOne(e => e.Order) + .ExcludeFromMigrations(false); + + Assert.False(foreignKey.IsExcludedFromMigrations()); + } + [ConditionalFact] public void Can_access_index() { @@ -1391,6 +1454,26 @@ public void Relational_relationship_methods_dont_break_out_of_the_generics() .HasOne(e => e.Details) .WithOne(e => e.Order) .HasConstraintName("Simon")); + + AssertIsGeneric( + modelBuilder + .Entity().HasMany(e => e.Orders) + .WithOne(e => e.Customer) + .ExcludeFromMigrations()); + + AssertIsGeneric( + modelBuilder + .Entity() + .HasOne(e => e.Customer) + .WithMany(e => e.Orders) + .ExcludeFromMigrations()); + + AssertIsGeneric( + modelBuilder + .Entity() + .HasOne(e => e.Details) + .WithOne(e => e.Order) + .ExcludeFromMigrations()); } [ConditionalFact] @@ -1434,6 +1517,24 @@ public void Can_access_relationship() Assert.Equal("Splow", relationshipBuilder.Metadata.GetConstraintName()); } + [ConditionalFact] + public void Can_access_relationship_ExcludeFromMigrations() + { + var modelBuilder = CreateBuilder(); + var entityTypeBuilder = modelBuilder.Entity(typeof(Splot), ConfigurationSource.Convention); + entityTypeBuilder.Property(typeof(int), "Id", ConfigurationSource.Convention); + var relationshipBuilder = entityTypeBuilder.HasRelationship("Splot", ["Id"], ConfigurationSource.Convention); + + Assert.NotNull(relationshipBuilder.ExcludeFromMigrations(true)); + Assert.True(relationshipBuilder.Metadata.IsExcludedFromMigrations()); + + Assert.NotNull(relationshipBuilder.ExcludeFromMigrations(true, fromDataAnnotation: true)); + Assert.True(relationshipBuilder.Metadata.IsExcludedFromMigrations()); + + Assert.Null(relationshipBuilder.ExcludeFromMigrations(false)); + Assert.True(relationshipBuilder.Metadata.IsExcludedFromMigrations()); + } + private void AssertIsGeneric(EntityTypeBuilder _) { } diff --git a/test/EFCore.Relational.Tests/Extensions/RelationalMetadataExtensionsTest.cs b/test/EFCore.Relational.Tests/Extensions/RelationalMetadataExtensionsTest.cs index efa6a48d9d3..08b1c3f4358 100644 --- a/test/EFCore.Relational.Tests/Extensions/RelationalMetadataExtensionsTest.cs +++ b/test/EFCore.Relational.Tests/Extensions/RelationalMetadataExtensionsTest.cs @@ -347,6 +347,37 @@ public void Can_get_and_set_column_foreign_key_name() Assert.Equal("FK_Order_Customer_CustomerId", foreignKey.GetConstraintName()); } + [ConditionalFact] + public void Can_get_and_set_foreign_key_excluded_from_migrations() + { + var modelBuilder = new ModelBuilder(); + + modelBuilder + .Entity() + .HasKey(e => e.Id); + + var foreignKey = modelBuilder + .Entity() + .HasOne() + .WithOne() + .HasForeignKey(e => e.CustomerId) + .Metadata; + + Assert.False(foreignKey.IsExcludedFromMigrations()); + + foreignKey.SetIsExcludedFromMigrations(true); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + + foreignKey.SetIsExcludedFromMigrations(false); + + Assert.False(foreignKey.IsExcludedFromMigrations()); + + foreignKey.SetIsExcludedFromMigrations(null); + + Assert.False(foreignKey.IsExcludedFromMigrations()); + } + [ConditionalFact] public void Can_get_and_set_index_name() { diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index 0eb2ae524f5..5a0ea588aee 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -1593,6 +1593,24 @@ public virtual void Detects_duplicate_foreignKey_names_within_hierarchy_with_dif modelBuilder); } + [ConditionalFact] + public virtual void Detects_duplicate_foreignKey_names_within_hierarchy_with_different_excluded_from_migrations() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(c => c.Name).HasPrincipalKey(p => p.Name) + .HasConstraintName("FK_Animal_Person_Name").ExcludeFromMigrations(); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(d => d.Name).HasPrincipalKey(p => p.Name) + .HasConstraintName("FK_Animal_Person_Name"); + + VerifyError( + RelationalStrings.DuplicateForeignKeyExcludedFromMigrationsMismatch( + "{'" + nameof(Dog.Name) + "'}", nameof(Dog), + "{'" + nameof(Cat.Name) + "'}", nameof(Cat), + nameof(Animal), "FK_Animal_Person_Name"), + modelBuilder); + } + [ConditionalFact] public virtual void Passes_for_incompatible_foreignKeys_within_hierarchy() { diff --git a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs index 161b04c4483..5b53dbcc75b 100644 --- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs @@ -3573,6 +3573,55 @@ public void Add_foreign_key() Assert.Equal(ReferentialAction.NoAction, addFkOperation.OnUpdate); }); + [ConditionalFact] + public void Add_foreign_key_excluded_from_migrations() + => Execute( + common => common.Entity( + "Amoeba", + x => + { + x.ToTable("Amoeba", "dbo"); + x.Property("Id"); + x.Property("ParentId"); + }), + _ => { }, + target => target.Entity( + "Amoeba", + x => x.HasOne("Amoeba").WithMany().HasForeignKey("ParentId").ExcludeFromMigrations() + ), + operations => + { + var createIndexOperation = Assert.IsType(Assert.Single(operations)); + Assert.Equal("dbo", createIndexOperation.Schema); + Assert.Equal("Amoeba", createIndexOperation.Table); + Assert.Equal("IX_Amoeba_ParentId", createIndexOperation.Name); + Assert.Equal(new[] { "ParentId" }, createIndexOperation.Columns); + }); + + [ConditionalFact] + public void Remove_foreign_key_excluded_from_migrations() + => Execute( + common => common.Entity( + "Amoeba", + x => + { + x.ToTable("Amoeba", "dbo"); + x.Property("Id"); + x.Property("ParentId"); + }), + source => source.Entity( + "Amoeba", + x => x.HasOne("Amoeba").WithMany().HasForeignKey("ParentId").ExcludeFromMigrations() + ), + _ => { }, + operations => + { + var dropIndexOperation = Assert.IsType(Assert.Single(operations)); + Assert.Equal("dbo", dropIndexOperation.Schema); + Assert.Equal("Amoeba", dropIndexOperation.Table); + Assert.Equal("IX_Amoeba_ParentId", dropIndexOperation.Name); + }); + [ConditionalFact] public void Add_optional_foreign_key() => Execute( diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 8f8dd721554..7385d49470e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -3588,6 +3588,16 @@ public override async Task Drop_foreign_key() """); } + public override async Task Add_foreign_key_excluded_from_migrations() + { + await base.Add_foreign_key_excluded_from_migrations(); + + AssertSql( + """ +CREATE INDEX [IX_Orders_CustomerId] ON [Orders] ([CustomerId]); +"""); + } + public override async Task Add_unique_constraint() { await base.Add_unique_constraint(); diff --git a/test/EFCore.Sqlite.FunctionalTests/Migrations/MigrationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Migrations/MigrationsSqliteTest.cs index 486fe39f437..83921a5a30d 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Migrations/MigrationsSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Migrations/MigrationsSqliteTest.cs @@ -1623,6 +1623,16 @@ public override async Task Drop_foreign_key() """); } + public override async Task Add_foreign_key_excluded_from_migrations() + { + await base.Add_foreign_key_excluded_from_migrations(); + + AssertSql( + """ +CREATE INDEX "IX_Orders_CustomerId" ON "Orders" ("CustomerId"); +"""); + } + public override async Task Add_unique_constraint() { await base.Add_unique_constraint();