diff --git a/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/AnnotationCodeGenerator.cs index 1762c7e9f1c..7fdac56089c 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.ExcludeForeignKeyFromMigrations), + 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..3ce5986f5d1 100644 --- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs +++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs @@ -2168,6 +2168,10 @@ public override void Generate(IForeignKey foreignKey, CSharpRuntimeAnnotationCod { parameters.Annotations.Remove(RelationalAnnotationNames.ForeignKeyMappings); } + else + { + 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..276d2e75fb0 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 ExcludeForeignKeyFromMigrations( + 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 ExcludeForeignKeyFromMigrations( + this ReferenceCollectionBuilder referenceCollectionBuilder, + bool excluded = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceCollectionBuilder)ExcludeForeignKeyFromMigrations( + (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 ExcludeForeignKeyFromMigrations( + 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 ExcludeForeignKeyFromMigrations( + this ReferenceReferenceBuilder referenceReferenceBuilder, + bool excluded = true) + where TEntity : class + where TRelatedEntity : class + => (ReferenceReferenceBuilder)ExcludeForeignKeyFromMigrations( + (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 ExcludeForeignKeyFromMigrations( + 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 ExcludeForeignKeyFromMigrations( + this OwnershipBuilder ownershipBuilder, + bool excluded = true) + where TEntity : class + where TDependentEntity : class + => (OwnershipBuilder)ExcludeForeignKeyFromMigrations( + (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? ExcludeForeignKeyFromMigrations( + this IConventionForeignKeyBuilder relationship, + bool? excluded, + bool fromDataAnnotation = false) + { + if (!relationship.CanSetExcludeForeignKeyFromMigrations(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 CanSetExcludeForeignKeyFromMigrations( + 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..dd07162f799 100644 --- a/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs +++ b/src/EFCore.Relational/Metadata/Conventions/RelationalRuntimeModelConvention.cs @@ -506,6 +506,10 @@ protected override void ProcessForeignKeyAnnotations( { annotations.Remove(RelationalAnnotationNames.ForeignKeyMappings); } + else + { + annotations.Remove(RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations); + } } private RuntimeStoredProcedure Create(IStoredProcedure storedProcedure, RuntimeEntityType runtimeEntityType) 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..91d50924a0e 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1400,6 +1400,7 @@ protected virtual IEnumerable Diff( Add, Remove, (s, t, context) => s.Name == t.Name + && s.IsExcludedFromMigrations == t.IsExcludedFromMigrations && s.Columns.Select(c => c.Name).SequenceEqual( t.Columns.Select(c => context.FindSource(c)?.Name)) && s.PrincipalTable == context.FindSource(t.PrincipalTable) @@ -1429,7 +1430,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 +1458,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..145e0c2fd26 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) + .ExcludeForeignKeyFromMigrations(); + }, + 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() + .ExcludeForeignKeyFromMigrations(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..1ff3b12810d 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").ExcludeForeignKeyFromMigrations(), + 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.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs index fca2fceb55a..cffd0c597ab 100644 --- a/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs +++ b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalModelBuilderTest.cs @@ -996,14 +996,92 @@ public virtual void Can_use_table_splitting() } } - public abstract class RelationalOneToManyTestBase(RelationalModelBuilderFixture fixture) : OneToManyTestBase(fixture); + public abstract class RelationalOneToManyTestBase(RelationalModelBuilderFixture fixture) : OneToManyTestBase(fixture) + { + [ConditionalFact] + public virtual void Can_exclude_foreign_key_from_migrations_for_one_to_many() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder + .Entity().HasMany(e => e.Orders).WithOne(e => e.Customer) + .ExcludeForeignKeyFromMigrations(); + + var model = modelBuilder.FinalizeModel(); + + var foreignKey = model.FindEntityType(typeof(Order))!.GetForeignKeys() + .Single(fk => fk.PrincipalEntityType.ClrType == typeof(Customer)); - public abstract class RelationalManyToOneTestBase(RelationalModelBuilderFixture fixture) : ManyToOneTestBase(fixture); + Assert.True(foreignKey.IsExcludedFromMigrations()); + } + } - public abstract class RelationalOneToOneTestBase(RelationalModelBuilderFixture fixture) : OneToOneTestBase(fixture); + public abstract class RelationalManyToOneTestBase(RelationalModelBuilderFixture fixture) : ManyToOneTestBase(fixture) + { + [ConditionalFact] + public virtual void Can_exclude_foreign_key_from_migrations_for_many_to_one() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder + .Entity().HasOne(e => e.Customer).WithMany(e => e.Orders) + .ExcludeForeignKeyFromMigrations(); + + var model = modelBuilder.FinalizeModel(); + + var foreignKey = model.FindEntityType(typeof(Order))!.GetForeignKeys() + .Single(fk => fk.PrincipalEntityType.ClrType == typeof(Customer)); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + } + } + + public abstract class RelationalOneToOneTestBase(RelationalModelBuilderFixture fixture) : OneToOneTestBase(fixture) + { + [ConditionalFact] + public virtual void Can_exclude_foreign_key_from_migrations_for_one_to_one() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder + .Entity().HasOne(e => e.Details).WithOne(e => e.Order) + .HasPrincipalKey(e => e.OrderId) + .ExcludeForeignKeyFromMigrations(); + + var model = modelBuilder.FinalizeModel(); + + var foreignKey = model.FindEntityType(typeof(OrderDetails))!.GetForeignKeys().Single(); + + Assert.True(foreignKey.IsExcludedFromMigrations()); + } + } public abstract class RelationalManyToManyTestBase(RelationalModelBuilderFixture fixture) : ManyToManyTestBase(fixture) { + [ConditionalFact] + public virtual void Can_exclude_foreign_key_from_migrations_for_many_to_many() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Ignore(); + + modelBuilder.Entity() + .HasMany(e => e.Categories) + .WithMany(e => e.Products) + .UsingEntity( + right => right.HasOne().WithMany().ExcludeForeignKeyFromMigrations(), + left => left.HasOne().WithMany().ExcludeForeignKeyFromMigrations()); + + var model = modelBuilder.FinalizeModel(); + + var joinEntityType = model.GetEntityTypes() + .Single(e => e.ClrType == typeof(Dictionary) && e.Name.Contains("Category")); + foreach (var foreignKey in joinEntityType.GetForeignKeys()) + { + Assert.True(foreignKey.IsExcludedFromMigrations()); + } + } + [ConditionalFact] // Issue #27990 public virtual void Can_use_ForeignKeyAttribute_with_InversePropertyAttribute() { @@ -1142,6 +1220,33 @@ protected class MotorBauart public abstract class RelationalOwnedTypesTestBase(RelationalModelBuilderFixture fixture) : OwnedTypesTestBase(fixture) { + [ConditionalFact] + public virtual void Can_exclude_foreign_key_from_migrations_for_owned_type() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder.Entity().OwnsOne( + b => b.Label, lb => + { + lb.Ignore(l => l.Book); + lb.WithOwner().ExcludeForeignKeyFromMigrations(); + }); + modelBuilder.Entity() + .OwnsOne(b => b.AlternateLabel); + + var model = modelBuilder.FinalizeModel(); + + var ownedType = model.FindEntityType(typeof(BookLabel), nameof(Book.Label), model.FindEntityType(typeof(Book))!)!; + var foreignKey = ownedType.GetForeignKeys().Single(); + + Assert.True(foreignKey.IsOwnership); + Assert.True(foreignKey.IsExcludedFromMigrations()); + } + [ConditionalFact] public virtual void Can_use_table_splitting_with_owned_reference() { diff --git a/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs index d2e08888ee2..23e749090a7 100644 --- a/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs +++ b/test/EFCore.Relational.Specification.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs @@ -1605,6 +1605,39 @@ public static ModelBuilderTest.TestReferenceCollectionBuilder ExcludeForeignKeyFromMigrations + ( + this ModelBuilderTest.TestOwnershipBuilder builder, + bool excluded = true) + where TOwnerEntity : class + where TDependentEntity : class + { + builder.Metadata.SetIsExcludedFromMigrations(excluded); + return builder; + } + + public static ModelBuilderTest.TestReferenceReferenceBuilder ExcludeForeignKeyFromMigrations + ( + this ModelBuilderTest.TestReferenceReferenceBuilder builder, + bool excluded = true) + where TOwnerEntity : class + where TDependentEntity : class + { + builder.Metadata.SetIsExcludedFromMigrations(excluded); + return builder; + } + + public static ModelBuilderTest.TestReferenceCollectionBuilder ExcludeForeignKeyFromMigrations + ( + this ModelBuilderTest.TestReferenceCollectionBuilder builder, + bool excluded = true) + where TOwnerEntity : class + where TDependentEntity : class + { + builder.Metadata.SetIsExcludedFromMigrations(excluded); + return builder; + } + public static ModelBuilderTest.TestIndexBuilder HasFilter( this ModelBuilderTest.TestIndexBuilder builder, string? filterExpression) diff --git a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs index 894a8dcf14f..897a77fbeb4 100644 --- a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs @@ -81,6 +81,10 @@ protected override void BuildBigModel(ModelBuilder modelBuilder, bool jsonColumn } }); + eb.HasOne(e => e.Dependent).WithOne(e => e.Principal) + .HasForeignKey>() + .ExcludeForeignKeyFromMigrations(); + eb.HasMany(e => e.Principals).WithMany(e => (ICollection>>)e.Deriveds) .UsingEntity(jb => { @@ -213,6 +217,7 @@ protected override void AssertBigModel(IModel model, bool jsonColumns) var dependentNavigation = principalDerived.GetDeclaredNavigations().First(); var dependentForeignKey = dependentNavigation.ForeignKey; + Assert.False(dependentForeignKey.IsExcludedFromMigrations()); var referenceOwnedNavigation = principalBase.GetNavigations().Single(); var referenceOwnedType = referenceOwnedNavigation.TargetEntityType; diff --git a/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs b/test/EFCore.Relational.Tests/Design/AnnotationCodeGeneratorTest.cs index d390013a875..9d9f78bb052 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_ExcludeForeignKeyFromMigrations() + { + var modelBuilder = CreateModelBuilder(); + modelBuilder.Entity("Blog", x => + { + x.Property("Id"); + x.Property("ParentId"); + x.HasOne("Blog").WithMany().HasForeignKey("ParentId").ExcludeForeignKeyFromMigrations(); + }); + 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("ExcludeForeignKeyFromMigrations", 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..b331c45608d 100644 --- a/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs +++ b/test/EFCore.Relational.Tests/Extensions/RelationalBuilderExtensionsTest.cs @@ -1434,6 +1434,24 @@ public void Can_access_relationship() Assert.Equal("Splow", relationshipBuilder.Metadata.GetConstraintName()); } + [ConditionalFact] + public void Can_access_relationship_ExcludeForeignKeyFromMigrations() + { + 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.ExcludeForeignKeyFromMigrations(true)); + Assert.True(relationshipBuilder.Metadata.IsExcludedFromMigrations()); + + Assert.NotNull(relationshipBuilder.ExcludeForeignKeyFromMigrations(true, fromDataAnnotation: true)); + Assert.True(relationshipBuilder.Metadata.IsExcludedFromMigrations()); + + Assert.Null(relationshipBuilder.ExcludeForeignKeyFromMigrations(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..7c1a2462f8d 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").ExcludeForeignKeyFromMigrations(); + 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..aed949d96a0 100644 --- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs @@ -3573,6 +3573,126 @@ 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").ExcludeForeignKeyFromMigrations() + ), + 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").ExcludeForeignKeyFromMigrations() + ), + _ => { }, + 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 Exclude_existing_foreign_key_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") + ), + target => target.Entity( + "Amoeba", + x => x.HasOne("Amoeba").WithMany().HasForeignKey("ParentId").ExcludeForeignKeyFromMigrations() + ), + upOps => + { + var dropFkOperation = Assert.IsType(Assert.Single(upOps)); + Assert.Equal("dbo", dropFkOperation.Schema); + Assert.Equal("Amoeba", dropFkOperation.Table); + Assert.Equal("FK_Amoeba_Amoeba_ParentId", dropFkOperation.Name); + }, + downOps => + { + var addFkOperation = Assert.IsType(Assert.Single(downOps)); + Assert.Equal("dbo", addFkOperation.Schema); + Assert.Equal("Amoeba", addFkOperation.Table); + Assert.Equal("FK_Amoeba_Amoeba_ParentId", addFkOperation.Name); + }); + + [ConditionalFact] + public void Exclude_existing_foreign_key_from_migrations_and_change_delete_behavior() + => 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").OnDelete(DeleteBehavior.Restrict) + ), + target => target.Entity( + "Amoeba", + x => x.HasOne("Amoeba").WithMany().HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade) + .ExcludeForeignKeyFromMigrations() + ), + upOps => + { + var dropFkOperation = Assert.IsType(Assert.Single(upOps)); + Assert.Equal("dbo", dropFkOperation.Schema); + Assert.Equal("Amoeba", dropFkOperation.Table); + Assert.Equal("FK_Amoeba_Amoeba_ParentId", dropFkOperation.Name); + }, + downOps => + { + var addFkOperation = Assert.IsType(Assert.Single(downOps)); + Assert.Equal("dbo", addFkOperation.Schema); + Assert.Equal("Amoeba", addFkOperation.Table); + Assert.Equal("FK_Amoeba_Amoeba_ParentId", addFkOperation.Name); + Assert.Equal(ReferentialAction.Restrict, addFkOperation.OnDelete); + }); + [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();