From 0a125ab61eb68a3fae2c0b1a49794e145b079b37 Mon Sep 17 00:00:00 2001 From: Artur Porowski <31070739+Vekz@users.noreply.github.com> Date: Wed, 18 May 2022 22:37:28 +0200 Subject: [PATCH] Attribute for configuring DeleteBehavior Fixes #9621 Added: DeleteBehaviorAttribute - Stores DeleteBehavior set on the property. DeleteBehaviorAttributeConvention - Triggers on ForeignKeyAdded, checks if its navigation has DeleteBehavior. If so then sets DeleteBehavior of this foreign key. Moved: DeleteBehavior => EFCore.Abstractions and added TypeForward in EFCore --- .../DeleteBehavior.cs | 54 ++- .../DeleteBehaviorAttribute.cs | 30 ++ .../DeleteBehaviorAttributeConvention.cs | 111 +++++++ .../ProviderConventionSetBuilder.cs | 5 + src/EFCore/Properties/CoreStrings.Designer.cs | 60 ++-- src/EFCore/Properties/CoreStrings.resx | 80 ++--- src/EFCore/Properties/TypeForwards.cs | 1 + .../Migrations/ModelSnapshotSqlServerTest.cs | 1 + .../Migrations/MigrationsTestBase.cs | 1 + .../DeleteBehaviorAttributeConventionTest.cs | 307 ++++++++++++++++++ .../NavigationAttributeConventionTest.cs | 64 ++++ 11 files changed, 625 insertions(+), 89 deletions(-) rename src/{EFCore => EFCore.Abstractions}/DeleteBehavior.cs (68%) create mode 100644 src/EFCore.Abstractions/DeleteBehaviorAttribute.cs create mode 100644 src/EFCore/Metadata/Conventions/DeleteBehaviorAttributeConvention.cs create mode 100644 test/EFCore.Tests/Metadata/Conventions/DeleteBehaviorAttributeConventionTest.cs diff --git a/src/EFCore/DeleteBehavior.cs b/src/EFCore.Abstractions/DeleteBehavior.cs similarity index 68% rename from src/EFCore/DeleteBehavior.cs rename to src/EFCore.Abstractions/DeleteBehavior.cs index 52b7e1eb54c..ecbd5eb6564 100644 --- a/src/EFCore/DeleteBehavior.cs +++ b/src/EFCore.Abstractions/DeleteBehavior.cs @@ -10,12 +10,12 @@ namespace Microsoft.EntityFrameworkCore; /// /// /// Behaviors in the database are dependent on the database schema being created -/// appropriately. Using Entity Framework Migrations or -/// will create the appropriate schema. +/// appropriately. Using Entity Framework Migrations or +/// EnsureCreated() will create the appropriate schema. /// /// /// Note that the in-memory behavior for entities that are currently tracked by -/// the can be different from the behavior that happens in the database. +/// the context can be different from the behavior that happens in the database. /// /// /// See Cascade delete and deleting orphans in EF Core for more information and @@ -25,16 +25,17 @@ namespace Microsoft.EntityFrameworkCore; public enum DeleteBehavior { /// - /// For entities being tracked by the , the values of foreign key properties in + /// For entities being tracked by the context, the values of foreign key properties in /// dependent entities are set to null when the related principal is deleted. /// This helps keep the graph of entities in a consistent state while they are being tracked, such that a /// fully consistent graph can then be written to the database. If a property cannot be set to null because - /// it is not a nullable type, then an exception will be thrown when is called. + /// it is not a nullable type, then an exception will be thrown when + /// SaveChanges() is called. /// /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database + /// EnsureCreated() method, then the behavior in the database /// is to generate an error if a foreign key constraint is violated. /// /// @@ -45,29 +46,30 @@ public enum DeleteBehavior ClientSetNull, /// - /// For entities being tracked by the , the values of foreign key properties in - /// dependent entities are set to null when the related principal is deleted. + /// For entities being tracked by the context, the values of foreign key properties in dependent entities + /// are set to null when the related principal is deleted. /// This helps keep the graph of entities in a consistent state while they are being tracked, such that a /// fully consistent graph can then be written to the database. If a property cannot be set to null because - /// it is not a nullable type, then an exception will be thrown when is called. + /// it is not a nullable type, then an exception will be thrown when + /// SaveChanges() is called. /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database - /// is to generate an error if a foreign key constraint is violated. + /// EnsureCreated() method, then the behavior in the database is to generate an error if a foreign key constraint is violated. /// Restrict, /// - /// For entities being tracked by the , the values of foreign key properties in + /// For entities being tracked by the context, the values of foreign key properties in /// dependent entities are set to null when the related principal is deleted. /// This helps keep the graph of entities in a consistent state while they are being tracked, such that a /// fully consistent graph can then be written to the database. If a property cannot be set to null because - /// it is not a nullable type, then an exception will be thrown when is called. + /// it is not a nullable type, then an exception will be thrown when + /// SaveChanges() is called. /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database is + /// EnsureCreated() method, then the behavior in the database is /// the same as is described above for tracked entities. Keep in mind that some databases cannot easily /// support this behavior, especially if there are cycles in relationships, in which case it may /// be better to use which will allow EF to cascade null values @@ -76,13 +78,13 @@ public enum DeleteBehavior SetNull, /// - /// For entities being tracked by the , dependent entities + /// For entities being tracked by the context, dependent entities /// will be deleted when the related principal is deleted. /// /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database is + /// EnsureCreated() method, then the behavior in the database is /// the same as is described above for tracked entities. Keep in mind that some databases cannot easily /// support this behavior, especially if there are cycles in relationships, in which case it may /// be better to use which will allow EF to perform cascade deletes @@ -96,27 +98,25 @@ public enum DeleteBehavior Cascade, /// - /// For entities being tracked by the , dependent entities + /// For entities being tracked by the context, dependent entities /// will be deleted when the related principal is deleted. /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database - /// is to generate an error if a foreign key constraint is violated. + /// EnsureCreated() method, then the behavior in the database is to generate an error if a foreign key constraint is violated. /// ClientCascade, /// - /// For entities being tracked by the , the values of foreign key properties in - /// dependent entities are set to null when the related principal is deleted. + /// For entities being tracked by the context, the values of foreign key properties in dependent entities are set to null when the related principal is deleted. /// This helps keep the graph of entities in a consistent state while they are being tracked, such that a /// fully consistent graph can then be written to the database. If a property cannot be set to null because - /// it is not a nullable type, then an exception will be thrown when is called. + /// it is not a nullable type, then an exception will be thrown when + /// SaveChanges() is called. /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database - /// is to generate an error if a foreign key constraint is violated. + /// EnsureCreated() method, then the behavior in the database is to generate an error if a foreign key constraint is violated. /// NoAction, @@ -126,15 +126,13 @@ public enum DeleteBehavior /// /// /// - /// For entities being tracked by the , the values of foreign key properties in - /// dependent entities are not changed when the related principal entity is deleted. + /// For entities being tracked by the context, the values of foreign key properties in dependent entities are not changed when the related principal entity is deleted. /// This can result in an inconsistent graph of entities where the values of foreign key properties do /// not match the relationships in the graph. /// /// /// If the database has been created from the model using Entity Framework Migrations or the - /// method, then the behavior in the database - /// is to generate an error if a foreign key constraint is violated. + /// EnsureCreated() method, then the behavior in the database is to generate an error if a foreign key constraint is violated. /// /// ClientNoAction diff --git a/src/EFCore.Abstractions/DeleteBehaviorAttribute.cs b/src/EFCore.Abstractions/DeleteBehaviorAttribute.cs new file mode 100644 index 00000000000..56a6757c8f8 --- /dev/null +++ b/src/EFCore.Abstractions/DeleteBehaviorAttribute.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore; + +/// +/// Configures the navigation property on the dependent side of a relationship +/// to indicate how a delete operation is applied to dependent entities +/// in a relationship when it is deleted or the relationship is severed. +/// +/// +/// See Modeling entity types and relationships for more information and examples. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class DeleteBehaviorAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The to be configured. + public DeleteBehaviorAttribute(DeleteBehavior behavior) + { + this.Behavior = behavior; + } + + /// + /// Gets the to be configured. + /// + public DeleteBehavior Behavior { get; } +} diff --git a/src/EFCore/Metadata/Conventions/DeleteBehaviorAttributeConvention.cs b/src/EFCore/Metadata/Conventions/DeleteBehaviorAttributeConvention.cs new file mode 100644 index 00000000000..fe381319862 --- /dev/null +++ b/src/EFCore/Metadata/Conventions/DeleteBehaviorAttributeConvention.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +/// +/// A convention that configures the delete behavior based on the applied on the property. +/// +/// +/// See Model building conventions for more information and examples. +/// +public class DeleteBehaviorAttributeConvention : PropertyAttributeConventionBase, INavigationAddedConvention, IForeignKeyPrincipalEndChangedConvention, IModelFinalizingConvention +{ + /// + /// Initializes a new instance of the class. + /// + /// Parameter object containing dependencies for this convention. + public DeleteBehaviorAttributeConvention(ProviderConventionSetBuilderDependencies dependencies) + : base(dependencies) + { + } + + /// + /// Called after a navigation is added to the entity type. + /// + /// The builder for the navigation. + /// Additional information associated with convention execution. + public virtual void ProcessNavigationAdded(IConventionNavigationBuilder navigationBuilder, IConventionContext context) + { + var navAttribute = navigationBuilder.Metadata.PropertyInfo?.GetCustomAttribute(); + if (navAttribute == null) + { + return; + } + + var foreignKey = navigationBuilder.Metadata.ForeignKey; + if (!navigationBuilder.Metadata.IsOnDependent && foreignKey.IsUnique) + { + return; + } + + foreignKey.Builder.OnDelete(navAttribute.Behavior, fromDataAnnotation: true); + } + + /// + /// Called after the principal end of a foreign key is changed. + /// + /// The builder for the foreign key. + /// Additional information associated with convention execution. + public virtual void ProcessForeignKeyPrincipalEndChanged(IConventionForeignKeyBuilder relationshipBuilder, IConventionContext context) + { + if (!relationshipBuilder.Metadata.IsUnique) + { + return; + } + + var navigation = relationshipBuilder.Metadata.DependentToPrincipal; + var navAttribute = navigation?.PropertyInfo?.GetCustomAttribute(); + if (navAttribute == null) + { + return; + } + + relationshipBuilder.OnDelete(navAttribute.Behavior, fromDataAnnotation: true); + } + + /// + /// Called when a model is being finalized. + /// + /// The builder for the model. + /// Additional information associated with convention execution. + public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) + { + foreach (var navigation in entityType.GetNavigations()) + { + var navAttribute = navigation.PropertyInfo?.GetCustomAttribute(); + if (navAttribute == null) + { + return; + } + + if (!navigation.IsOnDependent) + { + throw new InvalidOperationException(CoreStrings.DeleteBehaviorAttributeOnPrincipalProperty); + } + } + } + } + + /// + /// Called after a property is added to the entity type with an attribute on the associated CLR property or field. + /// + /// The builder for the property. + /// The attribute. + /// The member that has the attribute. + /// Additional information associated with convention execution. + protected override void ProcessPropertyAdded( + IConventionPropertyBuilder propertyBuilder, + DeleteBehaviorAttribute attribute, + MemberInfo clrMember, + IConventionContext context) + { + throw new InvalidOperationException(CoreStrings.DeleteBehaviorAttributeNotOnNavigationProperty); + } +} diff --git a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs index 19b1e2a76d3..3b934a7f0fd 100644 --- a/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs +++ b/src/EFCore/Metadata/Conventions/Infrastructure/ProviderConventionSetBuilder.cs @@ -116,6 +116,7 @@ public virtual ConventionSet CreateConventionSet() var backingFieldAttributeConvention = new BackingFieldAttributeConvention(Dependencies); var unicodeAttributeConvention = new UnicodeAttributeConvention(Dependencies); var precisionAttributeConvention = new PrecisionAttributeConvention(Dependencies); + var deleteBehaviorAttributeConvention = new DeleteBehaviorAttributeConvention(Dependencies); conventionSet.PropertyAddedConventions.Add(backingFieldAttributeConvention); conventionSet.PropertyAddedConventions.Add(backingFieldConvention); @@ -131,6 +132,7 @@ public virtual ConventionSet CreateConventionSet() conventionSet.PropertyAddedConventions.Add(foreignKeyPropertyDiscoveryConvention); conventionSet.PropertyAddedConventions.Add(unicodeAttributeConvention); conventionSet.PropertyAddedConventions.Add(precisionAttributeConvention); + conventionSet.PropertyAddedConventions.Add(deleteBehaviorAttributeConvention); conventionSet.EntityTypePrimaryKeyChangedConventions.Add(foreignKeyPropertyDiscoveryConvention); conventionSet.EntityTypePrimaryKeyChangedConventions.Add(valueGeneratorConvention); @@ -186,6 +188,7 @@ public virtual ConventionSet CreateConventionSet() conventionSet.NavigationAddedConventions.Add(foreignKeyPropertyDiscoveryConvention); conventionSet.NavigationAddedConventions.Add(relationshipDiscoveryConvention); conventionSet.NavigationAddedConventions.Add(foreignKeyAttributeConvention); + conventionSet.NavigationAddedConventions.Add(deleteBehaviorAttributeConvention); var manyToManyJoinEntityTypeConvention = new ManyToManyJoinEntityTypeConvention(Dependencies); conventionSet.SkipNavigationAddedConventions.Add(new NavigationBackingFieldAttributeConvention(Dependencies)); @@ -213,6 +216,7 @@ public virtual ConventionSet CreateConventionSet() conventionSet.ForeignKeyPrincipalEndChangedConventions.Add(foreignKeyPropertyDiscoveryConvention); conventionSet.ForeignKeyPrincipalEndChangedConventions.Add(requiredNavigationAttributeConvention); conventionSet.ForeignKeyPrincipalEndChangedConventions.Add(nonNullableNavigationConvention); + conventionSet.ForeignKeyPrincipalEndChangedConventions.Add(deleteBehaviorAttributeConvention); conventionSet.PropertyNullabilityChangedConventions.Add(foreignKeyPropertyDiscoveryConvention); @@ -241,6 +245,7 @@ public virtual ConventionSet CreateConventionSet() conventionSet.ModelFinalizingConventions.Add(new QueryFilterRewritingConvention(Dependencies)); conventionSet.ModelFinalizingConventions.Add(inversePropertyAttributeConvention); conventionSet.ModelFinalizingConventions.Add(backingFieldConvention); + conventionSet.ModelFinalizingConventions.Add(deleteBehaviorAttributeConvention); conventionSet.ModelFinalizedConventions.Add(new RuntimeModelConvention(Dependencies)); diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index d3a0d7337d5..6cf71024d68 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -574,6 +574,18 @@ public static string DbSetIncorrectGenericType(object? entityType, object? entit GetString("DbSetIncorrectGenericType", nameof(entityType), nameof(entityClrType), nameof(genericType)), entityType, entityClrType, genericType); + /// + /// The [DeleteBehavior] attribute may only be specified on navigation properties, and is not supported not on properties making up the foreign key. + /// + public static string DeleteBehaviorAttributeNotOnNavigationProperty + => GetString("DeleteBehaviorAttributeNotOnNavigationProperty"); + + /// + /// The [DeleteBehavior] attribute may only be specified on dependent side of the relationship. + /// + public static string DeleteBehaviorAttributeOnPrincipalProperty + => GetString("DeleteBehaviorAttributeOnPrincipalProperty"); + /// /// You are configuring a relationship between '{dependentEntityType}' and '{principalEntityType}' but have specified a foreign key on '{entityType}'. The foreign key must be defined on a type that is part of the relationship. /// @@ -1965,6 +1977,30 @@ public static string PoolingContextCtorError(object? contextType) public static string PoolingOptionsModified => GetString("PoolingOptionsModified"); + /// + /// The derived type '{derivedType}' cannot have the [PrimaryKey] attribute since primary keys may only be declared on the root type. Move the attribute to '{rootType}', or remove '{rootType}' from the model by using [NotMapped] attribute or calling 'EntityTypeBuilder.Ignore' on the base type in 'OnModelCreating'. + /// + public static string PrimaryKeyAttributeOnDerivedEntity(object? derivedType, object? rootType) + => string.Format( + GetString("PrimaryKeyAttributeOnDerivedEntity", nameof(derivedType), nameof(rootType)), + derivedType, rootType); + + /// + /// The [PrimaryKey] attribute on the entity type '{entityType}' is invalid because the property '{propertyName}' was marked as unmapped by [NotMapped] attribute or 'Ignore()' in 'OnModelCreating'. A primary key cannot use unmapped properties. + /// + public static string PrimaryKeyDefinedOnIgnoredProperty(object? entityType, object? propertyName) + => string.Format( + GetString("PrimaryKeyDefinedOnIgnoredProperty", nameof(entityType), nameof(propertyName)), + entityType, propertyName); + + /// + /// The [PrimaryKey] attribute on the entity type '{entityType}' references properties {properties}, but no property with name '{propertyName}' exists on that entity type or any of its base types. + /// + public static string PrimaryKeyDefinedOnNonExistentProperty(object? entityType, object? properties, object? propertyName) + => string.Format( + GetString("PrimaryKeyDefinedOnNonExistentProperty", nameof(entityType), nameof(properties), nameof(propertyName)), + entityType, properties, propertyName); + /// /// When creating the relationship between '{navigationSpecification1}' and '{navigationSpecification2}' the entity type '{targetEntityType}' cannot be set as principal. /// @@ -1997,30 +2033,6 @@ public static string PrincipalOwnedType(object? referencingEntityTypeOrNavigatio GetString("PrincipalOwnedType", nameof(referencingEntityTypeOrNavigation), nameof(referencedEntityTypeOrNavigation), nameof(ownedType)), referencingEntityTypeOrNavigation, referencedEntityTypeOrNavigation, ownedType); - /// - /// The derived type '{derivedType}' cannot have the [PrimaryKey] attribute since primary keys may only be declared on the root type. Move the attribute to '{rootType}', or remove '{rootType}' from the model by using [NotMapped] attribute or calling 'EntityTypeBuilder.Ignore' on the base type in 'OnModelCreating'. - /// - public static string PrimaryKeyAttributeOnDerivedEntity(object? derivedType, object? rootType) - => string.Format( - GetString("PrimaryKeyAttributeOnDerivedEntity", nameof(derivedType), nameof(rootType)), - derivedType, rootType); - - /// - /// The [PrimaryKey] attribute on the entity type '{entityType}' is invalid because the property '{propertyName}' was marked as unmapped by [NotMapped] attribute or 'Ignore()' in 'OnModelCreating'. A primary key cannot use unmapped properties. - /// - public static string PrimaryKeyDefinedOnIgnoredProperty(object? entityType, object? propertyName) - => string.Format( - GetString("PrimaryKeyDefinedOnIgnoredProperty", nameof(entityType), nameof(propertyName)), - entityType, propertyName); - - /// - /// The [PrimaryKey] attribute on the entity type '{entityType}' references properties {properties}, but no property with name '{propertyName}' exists on that entity type or any of its base types. - /// - public static string PrimaryKeyDefinedOnNonExistentProperty(object? entityType, object? properties, object? propertyName) - => string.Format( - GetString("PrimaryKeyDefinedOnNonExistentProperty", nameof(entityType), nameof(properties), nameof(propertyName)), - entityType, properties, propertyName); - /// /// '{property}' cannot be used as a property on entity type '{entityType}' because it is configured as a navigation. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index 5e6a7f2d2d5..e6debfc4779 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -1,17 +1,17 @@  - @@ -327,6 +327,12 @@ Cannot create DbSet for entity type '{entityType}' since it is of type '{entityClrType}' but the generic type provided is of type '{genericType}'. + + The [DeleteBehavior] attribute may only be specified on navigation properties, and is not supported not on properties making up the foreign key. + + + The [DeleteBehavior] attribute may only be specified on dependent side of the relationship. + You are configuring a relationship between '{dependentEntityType}' and '{principalEntityType}' but have specified a foreign key on '{entityType}'. The foreign key must be defined on a type that is part of the relationship. @@ -1154,6 +1160,15 @@ 'OnConfiguring' cannot be used to modify DbContextOptions when DbContext pooling is enabled. + + The derived type '{derivedType}' cannot have the [PrimaryKey] attribute since primary keys may only be declared on the root type. Move the attribute to '{rootType}', or remove '{rootType}' from the model by using [NotMapped] attribute or calling 'EntityTypeBuilder.Ignore' on the base type in 'OnModelCreating'. + + + The [PrimaryKey] attribute on the entity type '{entityType}' is invalid because the property '{propertyName}' was marked as unmapped by [NotMapped] attribute or 'Ignore()' in 'OnModelCreating'. A primary key cannot use unmapped properties. + + + The [PrimaryKey] attribute on the entity type '{entityType}' references properties {properties}, but no property with name '{propertyName}' exists on that entity type or any of its base types. + When creating the relationship between '{navigationSpecification1}' and '{navigationSpecification2}' the entity type '{targetEntityType}' cannot be set as principal. @@ -1166,15 +1181,6 @@ The relationship from '{referencingEntityTypeOrNavigation}' to '{referencedEntityTypeOrNavigation}' is not supported because the owned entity type '{ownedType}' cannot be on the principal side of a non-ownership relationship. Remove the relationship or configure the foreign key to be on '{ownedType}'. - - The derived type '{derivedType}' cannot have the [PrimaryKey] attribute since primary keys may only be declared on the root type. Move the attribute to '{rootType}', or remove '{rootType}' from the model by using [NotMapped] attribute or calling 'EntityTypeBuilder.Ignore' on the base type in 'OnModelCreating'. - - - The [PrimaryKey] attribute on the entity type '{entityType}' is invalid because the property '{propertyName}' was marked as unmapped by [NotMapped] attribute or 'Ignore()' in 'OnModelCreating'. A primary key cannot use unmapped properties. - - - The [PrimaryKey] attribute on the entity type '{entityType}' references properties {properties}, but no property with name '{propertyName}' exists on that entity type or any of its base types. - '{property}' cannot be used as a property on entity type '{entityType}' because it is configured as a navigation. @@ -1484,4 +1490,4 @@ Cannot start tracking the entry for entity type '{entityType}' because it was created by a different StateManager instance. - + \ No newline at end of file diff --git a/src/EFCore/Properties/TypeForwards.cs b/src/EFCore/Properties/TypeForwards.cs index a199f90d4e2..cb6700c69f9 100644 --- a/src/EFCore/Properties/TypeForwards.cs +++ b/src/EFCore/Properties/TypeForwards.cs @@ -8,3 +8,4 @@ [assembly: TypeForwardedTo(typeof(ObservableCollectionExtensions))] [assembly: TypeForwardedTo(typeof(ObservableCollectionListSource<>))] [assembly: TypeForwardedTo(typeof(SortableBindingList<>))] +[assembly: TypeForwardedTo(typeof(DeleteBehavior))] diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 1393c44b570..b554df26174 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -6215,6 +6215,7 @@ protected virtual ICollection GetReferences() => new List { BuildReference.ByName("Microsoft.EntityFrameworkCore"), + BuildReference.ByName("Microsoft.EntityFrameworkCore.Abstractions"), BuildReference.ByName("Microsoft.EntityFrameworkCore.Relational"), BuildReference.ByName("Microsoft.EntityFrameworkCore.SqlServer"), BuildReference.ByName("NetTopologySuite") diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index b8ecced9d14..6b1797563f7 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -2023,6 +2023,7 @@ protected IModel BuildModelFromSnapshotSource(string code) // Add standard EF references, a reference to the provider's assembly, and any extra references added by the provider's test suite build.References.Add(BuildReference.ByName("Microsoft.EntityFrameworkCore")); + build.References.Add(BuildReference.ByName("Microsoft.EntityFrameworkCore.Abstractions")); build.References.Add(BuildReference.ByName("Microsoft.EntityFrameworkCore.Relational")); var databaseProvider = Fixture.TestHelpers.CreateContextServices().GetRequiredService(); diff --git a/test/EFCore.Tests/Metadata/Conventions/DeleteBehaviorAttributeConventionTest.cs b/test/EFCore.Tests/Metadata/Conventions/DeleteBehaviorAttributeConventionTest.cs new file mode 100644 index 00000000000..59ee4cbc1de --- /dev/null +++ b/test/EFCore.Tests/Metadata/Conventions/DeleteBehaviorAttributeConventionTest.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable UnusedAutoPropertyAccessor.Local +// ReSharper disable UnusedMember.Local +// ReSharper disable ClassNeverInstantiated.Local +// ReSharper disable CollectionNeverUpdated.Local +// ReSharper disable InconsistentNaming +namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +public class DeleteBehaviorAttributeConventionTest +{ + [ConditionalFact] + public void Without_attribute_preserve_default_behavior() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.BlogId); + + var fk = modelBuilder.Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog).Metadata; + + Assert.Equal(DeleteBehavior.ClientSetNull, fk.DeleteBehavior); + } + + [ConditionalFact] + public void Correctly_set_restrict_delete_behavior_on_foreign_key() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.BlogId); + + var fk = modelBuilder.Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog_Restrict).Metadata; + + Assert.Equal(DeleteBehavior.Restrict, fk.DeleteBehavior); + } + + [ConditionalFact] + public void Correctly_set_delete_behavior_on_compound_foreign_key() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.BlogId); + modelBuilder.Entity() + .Property(e => e.BlogId2); + + var fk = modelBuilder.Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog_Compound).Metadata; + + Assert.Equal(DeleteBehavior.Cascade, fk.DeleteBehavior); + } + + [ConditionalFact] + public void Correctly_set_delete_behavior_on_two_different_foreign_keys() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.Blog_OneId); + modelBuilder.Entity() + .Property(e => e.Blog_TwoId); + + var fk_One = modelBuilder.Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog_One).Metadata; + + var fk_Two = modelBuilder.Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog_Two).Metadata; + + Assert.Equal(DeleteBehavior.Restrict, fk_One.DeleteBehavior); + Assert.Equal(DeleteBehavior.Cascade, fk_Two.DeleteBehavior); + } + + [ConditionalFact] + public void Throw_InvalidOperationException_if_attribute_was_set_on_one_of_foreign_keys_properties() + { + var modelBuilder = CreateModelBuilder(); + + Assert.Equal( + CoreStrings.DeleteBehaviorAttributeNotOnNavigationProperty, + Assert.Throws( + () => modelBuilder.Entity() + .Property(e => e.Blog_On_FK_PropertyId)).Message + ); + } + + [ConditionalFact] + public void Throw_InvalidOperationException_if_attribute_was_set_on_random_property() + { + var modelBuilder = CreateModelBuilder(); + + Assert.Equal( + CoreStrings.DeleteBehaviorAttributeNotOnNavigationProperty, + Assert.Throws( + () => modelBuilder.Entity() + .Property(e => e.Blog_On_PropertyId)).Message + ); + } + + [ConditionalFact] + public void Throw_InvalidOperationException_if_attribute_was_set_on_principal_navigation_property() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.Blog_On_PrincipalId); + + Assert.Equal( + CoreStrings.DeleteBehaviorAttributeOnPrincipalProperty, + Assert.Throws(() => modelBuilder.FinalizeModel()).Message + ); + } + + [ConditionalFact] + public void Throw_InvalidOperationException_if_attribute_was_set_on_principal_one_to_one_relationship() + { + var modelBuilder = CreateModelBuilder(); + + modelBuilder.Entity() + .Property(e => e.Blog_On_PrincipalId); + + Assert.Equal( + CoreStrings.DeleteBehaviorAttributeOnPrincipalProperty, + Assert.Throws(() => modelBuilder.FinalizeModel()).Message + ); + } + + private static ModelBuilder CreateModelBuilder() + => InMemoryTestHelpers.Instance.CreateConventionBuilder(); + + #region DeleteBehaviorAttribute not set + private class Blog + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post + { + public int Id { get; set; } + + public Blog Blog { get; set; } + + public int? BlogId { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set to Restrict + private class Blog_Restrict + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post_Restrict + { + public int Id { get; set; } + + [DeleteBehavior(DeleteBehavior.Restrict)] + public Blog_Restrict Blog_Restrict { get; set; } + + public int? BlogId { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set on compound key + private class Blog_Compound + { + [Key] + [Column(Order=0)] + public int Id { get; set; } + [Key] + [Column(Order=1)] + public int Id2 { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post_Compound + { + public int Id { get; set; } + + [ForeignKey("BlogId, BlogId2")] + [DeleteBehavior(DeleteBehavior.Cascade)] + public Blog_Compound Blog_Compound { get; set; } + + [Column(Order = 0)] + public int? BlogId { get; set; } + + [Column(Order = 1)] + public int? BlogId2 { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set on two different foreign keys + private class Blog_One + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + private class Blog_Two + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post_Both + { + public int Id { get; set; } + + [DeleteBehavior(DeleteBehavior.Restrict)] + public Blog_One Blog_One { get; set; } + + [DeleteBehavior(DeleteBehavior.Cascade)] + public Blog_Two Blog_Two { get; set; } + + public int? Blog_OneId { get; set; } + + public int? Blog_TwoId { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set on one of foreign key's properties + private class Blog_On_FK_Property + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post_On_FK_Property + { + public int Id { get; set; } + + public Blog_On_FK_Property Blog_On_FK_Property { get; set; } + + [DeleteBehavior(DeleteBehavior.Restrict)] + public int? Blog_On_FK_PropertyId { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set on random property + private class Blog_On_Property + { + public int Id { get; set; } + + public ICollection Posts { get; set; } + } + + private class Post_On_Property + { + [DeleteBehavior(DeleteBehavior.Restrict)] + public int Id { get; set; } + + public Blog_On_Property Blog_On_Property { get; set; } + + public int? Blog_On_PropertyId { get; set; } + } + #endregion + #region DeleteBehaviourAttribute set on principal navigation property + private class Blog_On_Principal + { + public int Id { get; set; } + + [DeleteBehavior(DeleteBehavior.Restrict)] + public ICollection Posts { get; set; } + } + + private class Post_On_Principal + { + public int Id { get; set; } + + public Blog_On_Principal Blog_On_Principal { get; set; } + + public int? Blog_On_PrincipalId { get; set; } + } + #endregion + + #region DeleteBehaviourAttribute set on principal 1:1 relationship + private class Blog_On_Principal_OneToOne + { + public int Id { get; set; } + + [DeleteBehavior(DeleteBehavior.Restrict)] + public Post_On_Principal_OneToOne Post_On_Principal_OneToOne { get; set; } + } + + private class Post_On_Principal_OneToOne + { + public int Id { get; set; } + + public Blog_On_Principal_OneToOne Blog_On_Principal_OneToOne { get; set; } + + public int? Blog_On_PrincipalId { get; set; } + } + #endregion +} diff --git a/test/EFCore.Tests/Metadata/Conventions/NavigationAttributeConventionTest.cs b/test/EFCore.Tests/Metadata/Conventions/NavigationAttributeConventionTest.cs index 1fdcb16ce9e..126640c8873 100644 --- a/test/EFCore.Tests/Metadata/Conventions/NavigationAttributeConventionTest.cs +++ b/test/EFCore.Tests/Metadata/Conventions/NavigationAttributeConventionTest.cs @@ -889,6 +889,7 @@ private void RunRequiredNavigationAttributeConvention(InternalForeignKeyBuilder new RequiredNavigationAttributeConvention(dependencies) .ProcessNavigationAdded(navigation.Builder, context); + } private void RunNavigationBackingFieldAttributeConvention( @@ -967,6 +968,68 @@ public void BackingFieldAttribute_does_not_override_configuration_from_explicit_ #endregion + #region DeleteBehaviorAttribute + [ConditionalFact] + public void DeleteBehaviorAttribute_overrides_configuration_from_convention_source() + { + var dependentEntityTypeBuilder = CreateInternalEntityTypeBuilder(); + var principalEntityTypeBuilder = + dependentEntityTypeBuilder.ModelBuilder.Entity( + typeof(Principal), ConfigurationSource.Convention); + + var relationshipBuilder = dependentEntityTypeBuilder.HasRelationship( + principalEntityTypeBuilder.Metadata, + nameof(Dependent.Principal), + nameof(Principal.Dependents), + ConfigurationSource.Convention); + + var navigationBuilder = relationshipBuilder.Metadata.DependentToPrincipal.Builder; + var foreignKey = navigationBuilder.Metadata.ForeignKey; + foreignKey.SetDeleteBehavior(DeleteBehavior.NoAction, ConfigurationSource.Convention); + + RunDeleteBehaviorAttributeConvention(relationshipBuilder, navigationBuilder); + + Assert.Equal(DeleteBehavior.Restrict, foreignKey.DeleteBehavior); + } + + [ConditionalFact] + public void DeleteBehaviorAttribute_does_not_override_configuration_from_explicit_source() + { + var dependentEntityTypeBuilder = CreateInternalEntityTypeBuilder(); + var principalEntityTypeBuilder = + dependentEntityTypeBuilder.ModelBuilder.Entity( + typeof(Principal), ConfigurationSource.Convention); + + var relationshipBuilder = dependentEntityTypeBuilder.HasRelationship( + principalEntityTypeBuilder.Metadata, + nameof(Dependent.Principal), + nameof(Principal.Dependents), + ConfigurationSource.Convention); + + var navigationBuilder = relationshipBuilder.Metadata.DependentToPrincipal.Builder; + var foreignKey = navigationBuilder.Metadata.ForeignKey; + foreignKey.SetDeleteBehavior(DeleteBehavior.NoAction, ConfigurationSource.Explicit); + + RunDeleteBehaviorAttributeConvention(relationshipBuilder, navigationBuilder); + + Assert.Equal(DeleteBehavior.NoAction, foreignKey.DeleteBehavior); + } + + + private void RunDeleteBehaviorAttributeConvention( + InternalForeignKeyBuilder relationshipBuilder, + InternalNavigationBuilder navigationBuilder + ) + { + var dependencies = CreateDependencies(); + var context = new ConventionContext( + relationshipBuilder.Metadata.DeclaringEntityType.Model.ConventionDispatcher); + + new DeleteBehaviorAttributeConvention(dependencies) + .ProcessNavigationAdded(navigationBuilder, context); + } + #endregion + [ConditionalFact] public void Navigation_attribute_convention_runs_for_private_property() { @@ -1093,6 +1156,7 @@ private class Dependent [ForeignKey("PrincipalFk")] [InverseProperty("Dependent")] + [DeleteBehavior(DeleteBehavior.Restrict)] public Principal Principal { get; set; } public Principal AnotherPrincipal { get; set; }