From acd93f89a0e40a3a867ca6ecdd9ae994740b0b9a Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Sat, 9 Jan 2021 13:52:32 -0800 Subject: [PATCH] Re-write cascade delete documentation Fixes #2906 Fixes #828 Fixes #473 --- .../core/saving/cascade-delete.md | 676 ++++++++++++------ .../core/CascadeDeletes/CascadeDeletes.csproj | 15 + .../CascadeDeletes/IntroOptionalSamples.cs | 171 +++++ .../CascadeDeletes/IntroRequiredSamples.cs | 188 +++++ .../OptionalDependentsSamples.cs | 202 ++++++ samples/core/CascadeDeletes/Program.cs | 35 + .../RequiredDependentsSamples.cs | 207 ++++++ .../WithDatabaseCycleSamples.cs | 184 +++++ samples/core/Samples.sln | 6 + 9 files changed, 1475 insertions(+), 209 deletions(-) create mode 100644 samples/core/CascadeDeletes/CascadeDeletes.csproj create mode 100644 samples/core/CascadeDeletes/IntroOptionalSamples.cs create mode 100644 samples/core/CascadeDeletes/IntroRequiredSamples.cs create mode 100644 samples/core/CascadeDeletes/OptionalDependentsSamples.cs create mode 100644 samples/core/CascadeDeletes/Program.cs create mode 100644 samples/core/CascadeDeletes/RequiredDependentsSamples.cs create mode 100644 samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs diff --git a/entity-framework/core/saving/cascade-delete.md b/entity-framework/core/saving/cascade-delete.md index c401182cb3..1d5b9d6777 100644 --- a/entity-framework/core/saving/cascade-delete.md +++ b/entity-framework/core/saving/cascade-delete.md @@ -1,302 +1,560 @@ --- title: Cascade Delete - EF Core -description: Configuring delete behaviors for related entities when a principal entity is deleted +description: Configuring cascading behaviors triggered when an entity is deleted or severed from its principal/parent author: ajcvickers -ms.date: 10/27/2016 +ms.date: 01/07/2021 uid: core/saving/cascade-delete --- # Cascade Delete -Cascade delete is commonly used in database terminology to describe a characteristic that allows the deletion of a row to automatically trigger the deletion of related rows. A closely related concept also covered by EF Core delete behaviors is the automatic deletion of a child entity when it's relationship to a parent has been severed--this is commonly known as "deleting orphans". +Entity Framework Core (EF Core) represents relationships using foreign keys. An entity with a foreign key is the child or dependent entity in the relationship. This entity's foreign key value must match the primary key value (or an alternate key value) of the related principal/parent entity. -EF Core implements several different delete behaviors and allows for the configuration of the delete behaviors of individual relationships. EF Core also implements conventions that automatically configure useful default delete behaviors for each relationship based on the [requiredness of the relationship](xref:core/modeling/relationships#required-and-optional-relationships). +If the principal/parent entity is deleted, then the foreign key values of the dependents/children will no longer match the primary or alternate key of _any_ principal/parent. This is an invalid state, and will cause a referential constraint violation in most databases. -## Delete behaviors +There are two options for resolving this invalid state: -Delete behaviors are defined in the *DeleteBehavior* enumerator type and can be passed to the *OnDelete* fluent API to control whether the deletion of a principal/parent entity or the severing of the relationship to dependent/child entities should have a side effect on the dependent/child entities. +1. Set the FK values to null +2. Also delete the dependent/child entities -There are three actions EF can take when a principal/parent entity is deleted or the relationship to the child is severed: +The first option in only valid for optional relationships where the foreign key property (and the database column to which it is mapped) must be nullable. -* The child/dependent can be deleted -* The child's foreign key values can be set to null -* The child remains unchanged +The second option is valid for any kind of relationship and is known as "cascade delete". -> [!NOTE] -> The delete behavior configured in the EF Core model is only applied when the principal entity is deleted using EF Core and the dependent entities are loaded in memory (that is, for tracked dependents). A corresponding cascade behavior needs to be setup in the database to ensure data that is not being tracked by the context has the necessary action applied. If you use EF Core to create the database, this cascade behavior will be setup for you. +> [!TIP] +> This document describes cascade deletes (and deleting orphans) from the perspective of updating the database. It makes heavy use of concepts introduced in [Change Tracking in EF Core](xref:core/change-tracking/index) and [Changing Foreign Keys and Navigations](xref:core/change-tracking/relationship-changes). Make sure to fully understand these concepts before tackling the material here. -For the second action above, setting a foreign key value to null is not valid if foreign key is not nullable. (A non-nullable foreign key is equivalent to a required relationship.) In these cases, EF Core tracks that the foreign key property has been marked as null until SaveChanges is called, at which time an exception is thrown because the change cannot be persisted to the database. This is similar to getting a constraint violation from the database. +> [!TIP] +> You can run and debug into all the code in this document by [downloading the sample code from GitHub](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/CascadeDeletes). -There are four delete behaviors, as listed in the tables below. +## When cascading behaviors happen -### Optional relationships +Cascading deletes are needed when a dependent/child entity can no longer be associated with its current principal/parent. This can happen because the principal/parent is deleted, or it can happen when the principal/parent still exists but the dependent/child is no longer associated with it. -For optional relationships (nullable foreign key) it _is_ possible to save a null foreign key value, which results in the following effects: +### Deleting a principal/parent -| Behavior Name | Effect on dependent/child in memory | Effect on dependent/child in database | -|:----------------------------|:---------------------------------------|:---------------------------------------| -| **Cascade** | Entities are deleted | Entities are deleted | -| **ClientSetNull** (Default) | Foreign key properties are set to null | None | -| **SetNull** | Foreign key properties are set to null | Foreign key properties are set to null | -| **Restrict** | None | None | +Consider this simple model where `Blog` is the principal/parent in a relationship with `Post`, which is the dependent/child. `Post.BlogId` is a foreign key property, the value of which must match the `Post.Id` primary key of the post to which the blog belongs. -### Required relationships + +[!code-csharp[Model](../../../samples/core/CascadeDeletes/IntroRequiredSamples.cs?name=Model)] -> [!NOTE] -> In EF Core, unlike EF6, cascading effects do not happen immediately, but instead only when SaveChanges is called. +By convention, this relationship is configured as a required, since the `Post.BlogId` foreign key property is non-nullable. Required relationships are configured to use cascade deletes by default. See [Relationships](xref:core/modeling/relationships) for more information on modeling relationships. -## Entity deletion examples +When deleting a blog, all posts are cascade deleted. For example: -The code below is part of a [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Saving/CascadeDelete/) that can be downloaded and run. The sample shows what happens for each delete behavior for both optional and required relationships when a parent entity is deleted. + +[!code-csharp[Deleting_principal_parent_1](../../../samples/core/CascadeDeletes/IntroRequiredSamples.cs?name=Deleting_principal_parent_1)] -### DeleteBehavior.Cascade with required or optional relationship +SaveChanges generates the following SQL, using SQL Server as an example: -```output -After loading entities: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +```sql +-- Executed DbCommand (1ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Posts] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (0ms) [Parameters=[@p0='2'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Posts] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (2ms) [Parameters=[@p1='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Blogs] +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; +``` -After deleting blog '1': - Blog '1' is in state Deleted with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +### Severing a relationship -Saving changes: - DELETE FROM [Posts] WHERE [PostId] = 1 - DELETE FROM [Posts] WHERE [PostId] = 2 - DELETE FROM [Blogs] WHERE [BlogId] = 1 +Rather than deleting the blog, we could instead sever the relationship between each post and its blog. This can be done by setting the reference navigation `Post.Blog` to null for each post: -After SaveChanges: - Blog '1' is in state Detached with 2 posts referenced. - Post '1' is in state Detached with FK '1' and no reference to a blog. - Post '2' is in state Detached with FK '1' and no reference to a blog. -``` + +[!code-csharp[Severing_a_relationship_1](../../../samples/core/CascadeDeletes/IntroRequiredSamples.cs?name=Severing_a_relationship_1)] -* Blog is marked as Deleted -* Posts initially remain Unchanged since cascades do not happen until SaveChanges -* SaveChanges sends deletes for both dependents/children (posts) and then the principal/parent (blog) -* After saving, all entities are detached since they have now been deleted from the database +The relationship can also be severed by removing each post from the `Blog.Posts` collection navigation: -### DeleteBehavior.ClientSetNull or DeleteBehavior.SetNull with required relationship + +[!code-csharp[Severing_a_relationship_2](../../../samples/core/CascadeDeletes/IntroRequiredSamples.cs?name=Severing_a_relationship_2)] -Saving changes: - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 1 +In either case the result is the same: the blog is not deleted, but the posts that are no longer associated with any blog are deleted: -SaveChanges threw DbUpdateException: Cannot insert the value NULL into column 'BlogId', table 'EFSaving.CascadeDelete.dbo.Posts'; column does not allow nulls. UPDATE fails. The statement has been terminated. +```sql +-- Executed DbCommand (1ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Posts] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (0ms) [Parameters=[@p0='2'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Posts] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; ``` -* Blog is marked as Deleted -* Posts initially remain Unchanged since cascades do not happen until SaveChanges -* SaveChanges attempts to set the post FK to null, but this fails because the FK is not nullable +Deleting entities that are no longer associated with any principal/dependent is known as "deleting orphans". + +> [!TIP] +> Cascade delete and deleting orphans are closely related. Both result in deleting dependent/child entities when the relationship to their required principal/parent is severed. For cascade delete, this severing happens because the principal/parent is itself deleted. For orphans, the principal/parent entity still exists, but is no longer related to the dependent/child entities. + +## Where cascading behaviors happen + +Cascading behaviors can be applied to: -### DeleteBehavior.ClientSetNull or DeleteBehavior.SetNull with optional relationship +- Entities tracked by the current +- Entities in the database that have not been loaded into the context -```output -After loading entities: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +### Cascade delete of tracked entities -After deleting blog '1': - Blog '1' is in state Deleted with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +EF Core always applies configured cascading behaviors to tracked entities. This means that if the application loads all relevant dependent/child entities into the DbContext, as is shown in the examples above, then cascading behaviors will be correctly applied regardless of how the database is configured. -Saving changes: - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 1 - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 2 - DELETE FROM [Blogs] WHERE [BlogId] = 1 +> [!TIP] +> The exact timing of when cascading behaviors happen to tracked entities can be controlled using and . See [Changing Foreign Keys and Navigations](xref:core/change-tracking/relationship-changes) for more information. -After SaveChanges: - Blog '1' is in state Detached with 2 posts referenced. - Post '1' is in state Unchanged with FK 'null' and no reference to a blog. - Post '2' is in state Unchanged with FK 'null' and no reference to a blog. +### Cascade delete in the database + +Many database systems also offer cascading behaviors that are triggered when an entity is deleted in the database. EF Core configures these behaviors based on the cascade delete behavior in the EF Core model when a database is created using or EF Core migrations. For example, using the model above, the following table is created for posts when using SQL Server: + +```sql +CREATE TABLE [Posts] ( + [Id] int NOT NULL IDENTITY, + [Title] nvarchar(max) NULL, + [Content] nvarchar(max) NULL, + [BlogId] int NOT NULL, + CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]), + CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]) ON DELETE CASCADE +); ``` -* Blog is marked as Deleted -* Posts initially remain Unchanged since cascades do not happen until SaveChanges -* SaveChanges attempts sets the FK of both dependents/children (posts) to null before deleting the principal/parent (blog) -* After saving, the principal/parent (blog) is deleted, but the dependents/children (posts) are still tracked -* The tracked dependents/children (posts) now have null FK values and their reference to the deleted principal/parent (blog) has been removed +Notice that the foreign key constraint defining the relationship between blogs and posts is configured with `ON DELETE CASCADE`. -### DeleteBehavior.Restrict with required or optional relationship +If we know that database is configured like this, then we can delete a blog _without first loading posts_ and the database will take care of deleting all the posts that were related to that blog. For example: -```output -After loading entities: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. + +[!code-csharp[Where_cascading_behaviors_happen_1](../../../samples/core/CascadeDeletes/IntroRequiredSamples.cs?name=Where_cascading_behaviors_happen_1)] + +Notice that there is no `Include` for posts, so they are not loaded. SaveChanges in this case will delete just the blog, since that's the only entity being tracked: + +```sql +-- Executed DbCommand (6ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Blogs] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; ``` -* Blog is marked as Deleted -* Posts initially remain Unchanged since cascades do not happen until SaveChanges -* Since *Restrict* tells EF to not automatically set the FK to null, it remains non-null and SaveChanges throws without saving +This will result in an exception if the foreign key constraint in the database is not configured for cascade deletes. However, in this case the posts are deleted by the database because it has been configured with `ON DELETE CASCADE` when it was created. -## Delete orphans examples +> [!NOTE] +> Databases don't typically have any way to automatically delete orphans. This is because while EF Core represents relationships using navigations as well of foreign keys, databases have only foreign keys and no navigations. This means that it is usually not possible to sever a relationship without loading both sides into the DbContext. -The code below is part of a [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/Saving/CascadeDelete/) that can be downloaded and run. The sample shows what happens for each delete behavior for both optional and required relationships when the relationship between a parent/principal and its children/dependents is severed. In this example, the relationship is severed by removing the dependents/children (posts) from the collection navigation property on the principal/parent (blog). However, the behavior is the same if the reference from dependent/child to principal/parent is instead nulled out. +### Database cascade limitations -[!code-csharp[Main](../../../samples/core/Saving/CascadeDelete/Sample.cs#DeleteOrphansVariations)] +Some databases, most notably SQL Server, have limitations on the cascade behaviors that form cycles. For example, consider the following model: -Let's walk through each variation to understand what is happening. + +[!code-csharp[Model](../../../samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs?name=Model)] -* Posts are marked as Modified because severing the relationship caused the FK to be marked as null - * If the FK is not nullable, then the actual value will not change even though it is marked as null -* SaveChanges sends deletes for dependents/children (posts) -* After saving, the dependents/children (posts) are detached since they have now been deleted from the database +This model has three relationships, all required and therefore configured to cascade delete by convention: -### DeleteBehavior.ClientSetNull or DeleteBehavior.SetNull with required relationship +- Deleting a blog will cascade delete all the related posts +- Deleting the author of posts will cause the authored posts to be cascade deleted +- Deleting the owner of a blog will cause the blog to be cascade deleted -```output -After loading entities: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +This is all reasonable (if a bit draconian in blog management policies!) but attempting to create a SQL Server database with these cascades configured results in the following exception: -After making posts orphans: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Modified with FK 'null' and no reference to a blog. - Post '2' is in state Modified with FK 'null' and no reference to a blog. +> Microsoft.Data.SqlClient.SqlException (0x80131904): Introducing FOREIGN KEY constraint 'FK_Posts_Person_AuthorId' on table 'Posts' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints. -Saving changes: - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 1 +There are two ways to handle this situation: -SaveChanges threw DbUpdateException: Cannot insert the value NULL into column 'BlogId', table 'EFSaving.CascadeDelete.dbo.Posts'; column does not allow nulls. UPDATE fails. The statement has been terminated. -``` +1. Change one or more of the relationships to not cascade delete. +2. Configure the database without one or more of these cascade deletes, then ensure all dependent entities are loaded so that EF Core can perform the cascading behavior. -* Posts are marked as Modified because severing the relationship caused the FK to be marked as null - * If the FK is not nullable, then the actual value will not change even though it is marked as null -* SaveChanges attempts to set the post FK to null, but this fails because the FK is not nullable +Taking the first approach with our example, we could make the blog-owner relationship optional by giving it a nullable foreign key property: -### DeleteBehavior.ClientSetNull or DeleteBehavior.SetNull with optional relationship + +[!code-csharp[NullableBlogId](../../../samples/core/CascadeDeletes/OptionalDependentsSamples.cs?name=NullableBlogId)] -```output -After loading entities: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK '1' and reference to blog '1'. - Post '2' is in state Unchanged with FK '1' and reference to blog '1'. +An optional relationship allows the blog to exist without an owner, which means cascade delete will no longer be configured by default. This means there is no longer a cycle in cascading actions, and the database can be created without error on SQL Server. -After making posts orphans: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Modified with FK 'null' and no reference to a blog. - Post '2' is in state Modified with FK 'null' and no reference to a blog. +Taking the second approach instead, we can keep the blog-owner relationship required and configured for cascade delete, but make this configuration only apply to tracked entities, not the database: -Saving changes: - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 1 - UPDATE [Posts] SET [BlogId] = NULL WHERE [PostId] = 2 + +[!code-csharp[OnModelCreating](../../../samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs?name=OnModelCreating)] -After SaveChanges: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Unchanged with FK 'null' and no reference to a blog. - Post '2' is in state Unchanged with FK 'null' and no reference to a blog. -``` +Now what happens if we load both a person and the blog they own, then delete the person? -* Posts are marked as Modified because severing the relationship caused the FK to be marked as null - * If the FK is not nullable, then the actual value will not change even though it is marked as null -* SaveChanges sets the FK of both dependents/children (posts) to null -* After saving, the dependents/children (posts) now have null FK values and their reference to the deleted principal/parent (blog) has been removed + +[!code-csharp[Database_cascade_limitations_1](../../../samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs?name=Database_cascade_limitations_1)] -After making posts orphans: - Blog '1' is in state Unchanged with 2 posts referenced. - Post '1' is in state Modified with FK '1' and no reference to a blog. - Post '2' is in state Modified with FK '1' and no reference to a blog. +EF Core will cascade the delete of the owner so that the blog is also deleted: -Saving changes: -SaveChanges threw InvalidOperationException: The association between entity types 'Blog' and 'Post' has been severed but the foreign key for this relationship cannot be set to null. If the dependent entity should be deleted, then setup the relationship to use cascade deletes. +```sql +-- Executed DbCommand (8ms) [Parameters=[@p0='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Blogs] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (2ms) [Parameters=[@p1='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [People] +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; ``` -* Posts are marked as Modified because severing the relationship caused the FK to be marked as null - * If the FK is not nullable, then the actual value will not change even though it is marked as null -* Since *Restrict* tells EF to not automatically set the FK to null, it remains non-null and SaveChanges throws without saving +However, if the blog is not loaded when the owner is deleted: + + +[!code-csharp[Database_cascade_limitations_2](../../../samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs?name=Database_cascade_limitations_2)] + +Then an exception will be thrown due to violation of the foreign key constraint in the database: + +> Microsoft.Data.SqlClient.SqlException: The DELETE statement conflicted with the REFERENCE constraint "FK_Blogs_People_OwnerId". The conflict occurred in database "Scratch", table "dbo.Blogs", column 'OwnerId'. +The statement has been terminated. + +## Cascading nulls -## Cascading to untracked entities +Optional relationships have nullable foreign key properties mapped to nullable database columns. This means that the foreign key value can be set to null when the current principal/parent is deleted or is severed from the dependent/child. -When you call *SaveChanges*, the cascade delete rules will be applied to any entities that are being tracked by the context. This is the situation in all the examples shown above, which is why SQL was generated to delete both the principal/parent (blog) and all the dependents/children (posts): +Lets look again at the examples from [When cascading behaviors happen](#when-cascading-behaviors-happen), but this time with an optional relationship represented by a nullable `Post.BlogId` foreign key property: + + +[!code-csharp[NullableBlogId](../../../samples/core/CascadeDeletes/OptionalDependentsSamples.cs?name=NullableBlogId)] + +This foreign key property will be set to null for each post when its related blog is deleted. For example, this code, which is the same as before: + + +[!code-csharp[Deleting_principal_parent_1b](../../../samples/core/CascadeDeletes/IntroOptionalSamples.cs?name=Deleting_principal_parent_1b)] + +Will now result in the following database updates when SaveChanges is called: ```sql -DELETE FROM [Posts] WHERE [PostId] = 1 -DELETE FROM [Posts] WHERE [PostId] = 2 -DELETE FROM [Blogs] WHERE [BlogId] = 1 +-- Executed DbCommand (2ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +UPDATE [Posts] SET [BlogId] = @p0 +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (0ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +UPDATE [Posts] SET [BlogId] = @p0 +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (1ms) [Parameters=[@p2='1'], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +DELETE FROM [Blogs] +WHERE [Id] = @p2; +SELECT @@ROWCOUNT; ``` -If only the principal is loaded--for example, when a query is made for a blog without an `Include(b => b.Posts)` to also include posts--then SaveChanges will only generate SQL to delete the principal/parent: +Likewise, if the relationship is severed using either of the examples from above: + + +[!code-csharp[Severing_a_relationship_1b](../../../samples/core/CascadeDeletes/IntroOptionalSamples.cs?name=Severing_a_relationship_1b)] + +Or: + + +[!code-csharp[Severing_a_relationship_2b](../../../samples/core/CascadeDeletes/IntroOptionalSamples.cs?name=Severing_a_relationship_2b)] + +Then the posts are updated with null foreign key values when SaveChanges is called: ```sql -DELETE FROM [Blogs] WHERE [BlogId] = 1 +-- Executed DbCommand (2ms) [Parameters=[@p1='1', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +UPDATE [Posts] SET [BlogId] = @p0 +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; + +-- Executed DbCommand (0ms) [Parameters=[@p1='2', @p0=NULL (DbType = Int32)], CommandType='Text', CommandTimeout='30'] +SET NOCOUNT ON; +UPDATE [Posts] SET [BlogId] = @p0 +WHERE [Id] = @p1; +SELECT @@ROWCOUNT; ``` -The dependents/children (posts) will only be deleted if the database has a corresponding cascade behavior configured. If you use EF to create the database, this cascade behavior will be setup for you. +See [Changing Foreign Keys and Navigations](xref:core/change-tracking/relationship-changes) for more information on how EF Core manages foreign keys and navigations as their values are changed. + +> [!NOTE] +> The fixup of relationships like this has been the default behavior of Entity Framework since the first version in 2008. Prior to EF Core it didn't have a name and was not possible to change. It is now known as `ClientSetNull` as described in the next section. + +Databases can also be configured to cascade nulls like this when a principal/parent in an optional relationship is deleted. However, this is much less common than using cascading deletes in the database. Using cascading deletes and cascading nulls in the database at the same time will almost always result in relationship cycles when using SQL Server. See the next section for more information on configuring cascading nulls. + +## Configuring cascading behaviors + +> [!TIP] +> Be sure to read sections above before coming here. The configuration options will likely not make sense if the preceding material is not understood. + +Cascade behaviors are configured per relationship using the method in . For example: + + +[!code-csharp[OnModelCreating](../../../samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs?name=OnModelCreating)] + +See [Relationships](xref:core/modeling/relationships) for more information on configuring relationships between entity types. + +`OnDelete` accepts a value from the, admittedly confusing, enum. This enum defines both the behavior of EF Core on tracked entities, and the configuration of cascade delete in the database. + +### Impact on database schema + +The following table shows the result of each `OnDelete` value on the foreign key constraint created by EF Core migrations or . + +| DeleteBehavior | Impact on database schema +|:----------------------|-------------------------- +| Cascade | ON DELETE CASCADE +| Restrict | ON DELETE NO ACTION +| NoAction | +| SetNull | ON DELETE SET NULL +| ClientSetNull | ON DELETE NO ACTION +| ClientCascade | ON DELETE NO ACTION +| ClientNoAction | + +> [!NOTE] +> This table is confusing and we plan to revisit this in a future release. See [GitHub Issue #21252](https://github.com/dotnet/efcore/issues/21252). + +The behaviors of `ON DELETE NO ACTION` and `ON DELETE RESTRICT` in relational databases are typically either identical or very similar. Despite what `NO ACTION` may imply, both of these options cause referential constraints to be enforced. The difference, when there is one, is _when_ the database checks the constraints. This is of academic interest only for most applications since it rarely effects behavior. Check your database documentation for the specific differences between `ON DELETE NO ACTION` and `ON DELETE RESTRICT` on your database system. + +The only values that will cause cascading behaviors on the database are `Cascade` and `SetNull`. All other values will configure the database to not cascade any changes. + +### Impact on SaveChanges behavior + +The tables in the following sections cover what happens to dependent/child entities when the principal/parent is deleted, or its relationship to the dependent/child entities is severed. Each table covers one of: + +- Optional (nullable FK) and required (non-nullable FK) relationships +- When dependents/children are loaded and tracked by the DbContext and when they exist only in the database + +#### Required relationship with dependents/children loaded + +| DeleteBehavior | On deleting principal/parent | On severing from principal/parent +|:------------------|------------------------------------------|---------------------------------------- +| Cascade | Dependents deleted by EF Core | Dependents deleted by EF Core +| Restrict | `InvalidOperationException` | `InvalidOperationException` +| NoAction | `InvalidOperationException` | `InvalidOperationException` +| SetNull | `SqlException` on creating database | `SqlException` on creating database +| ClientSetNull | `InvalidOperationException` | `InvalidOperationException` +| ClientCascade | Dependents deleted by EF Core | Dependents deleted by EF Core +| ClientNoAction | `DbUpdateException` | `InvalidOperationException` + +Notes: + +- The default for required relationships like this is `Cascade`. +- Using anything other than cascade delete for required relationships will result in an exception when SaveChanges is called. + - Typically, this is an `InvalidOperationException` from EF Core since the invalid state is detected in the loaded children/dependents. + - `ClientNoAction` forces EF Core to not check fixup dependents before sending them to the database, so in this case the database throws an exception, which is then wrapped in a `DbUpdateException` by SaveChanges. + - `SetNull` is rejected when creating the database since the foreign key column is not nullable. +- Since dependents/children are loaded they are always deleted by EF Core, and never left for the database to delete. + +#### Required relationship with dependents/children not loaded + +| DeleteBehavior | On deleting principal/parent | On severing from principal/parent +|:------------------|------------------------------------------|---------------------------------------- +| Cascade | Dependents deleted by database | N/A +| Restrict | `DbUpdateException` | N/A +| NoAction | `DbUpdateException` | N/A +| SetNull | `SqlException` on creating database | N/A +| ClientSetNull | `DbUpdateException` | N/A +| ClientCascade | `DbUpdateException` | N/A +| ClientNoAction | `DbUpdateException` | N/A + +Notes: + +- Severing a relationship is not valid here since the dependents/children are not loaded. +- The default for required relationships like this is `Cascade`. +- Using anything other than cascade delete for required relationships will result in an exception when SaveChanges is called. + - Typically, this is a `DbUpdateException` because the dependents/children are not loaded, and hence the invalid state can only be detected by the database. SaveChanges then wraps the database exception in a `DbUpdateException`. + - `SetNull` is rejected when creating the database since the foreign key column is not nullable. + +#### Optional relationship with dependents/children loaded + +| DeleteBehavior | On deleting principal/parent | On severing from principal/parent +|:------------------|------------------------------------------|---------------------------------------- +| Cascade | Dependents deleted by EF Core | Dependents deleted by EF Core +| Restrict | Dependent FKs set to null by EF Core | Dependent FKs set to null by EF Core +| NoAction | Dependent FKs set to null by EF Core | Dependent FKs set to null by EF Core +| SetNull | Dependent FKs set to null by EF Core | Dependent FKs set to null by EF Core +| ClientSetNull | Dependent FKs set to null by EF Core | Dependent FKs set to null by EF Core +| ClientCascade | Dependents deleted by EF Core | Dependents deleted by EF Core +| ClientNoAction | `DbUpdateException` | Dependent FKs set to null by EF Core + +Notes: + +- The default for optional relationships like this is `ClientSetNull`. +- Dependents/children are never deleted unless `Cascade` or `ClientCascade` are configured. +- All other values cause the dependent FKs to be set to null by EF Core... + - ...except `ClientNoAction` which tells EF Core not to touch the foreign keys of dependents/children when the principal/parent is deleted. The database therefore throws an exception, which is wrapped as a `DbUpdateException` by SaveChanges. + +#### Optional relationship with dependents/children not loaded + +| DeleteBehavior | On deleting principal/parent | On severing from principal/parent +|:------------------|------------------------------------------|---------------------------------------- +| Cascade | Dependents deleted by database | N/A +| Restrict | `DbUpdateException` | N/A +| NoAction | `DbUpdateException` | N/A +| SetNull | Dependent FKs set to null by database | N/A +| ClientSetNull | `DbUpdateException` | N/A +| ClientCascade | `DbUpdateException` | N/A +| ClientNoAction | `DbUpdateException` | N/A + +Notes: + +- Severing a relationship is not valid here since the dependents/children are not loaded. +- The default for optional relationships like this is `ClientSetNull`. +- Dependents/children must be loaded to avoid a database exception unless the database has been configured to cascade either deletes or nulls. diff --git a/samples/core/CascadeDeletes/CascadeDeletes.csproj b/samples/core/CascadeDeletes/CascadeDeletes.csproj new file mode 100644 index 0000000000..edd89cb4de --- /dev/null +++ b/samples/core/CascadeDeletes/CascadeDeletes.csproj @@ -0,0 +1,15 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + diff --git a/samples/core/CascadeDeletes/IntroOptionalSamples.cs b/samples/core/CascadeDeletes/IntroOptionalSamples.cs new file mode 100644 index 0000000000..968bd0a3bc --- /dev/null +++ b/samples/core/CascadeDeletes/IntroOptionalSamples.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace IntroOptional +{ + public static class IntroOptionalSamples + { + public static void Deleting_principal_parent_1b() + { + Console.WriteLine($">>>> Sample: {nameof(Deleting_principal_parent_1b)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Deleting_principal_parent_1b() + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + context.Remove(blog); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Severing_a_relationship_1b() + { + Console.WriteLine($">>>> Sample: {nameof(Severing_a_relationship_1b)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Severing_a_relationship_1b() + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + foreach (var post in blog.Posts) + { + post.Blog = null; + } + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Severing_a_relationship_2b() + { + Console.WriteLine($">>>> Sample: {nameof(Severing_a_relationship_2b)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Severing_a_relationship_1b() + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + blog.Posts.Clear(); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + } + + public static class Helpers + { + public static void RecreateCleanDatabase() + { + using var context = new BlogsContext(quiet: true); + + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + + public static void PopulateDatabase() + { + using var context = new BlogsContext(quiet: true); + + context.Add( + new Blog + { + Name = ".NET Blog", + Posts = + { + new Post + { + Title = "Announcing the Release of EF Core 5.0", + Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..." + }, + new Post + { + Title = "Announcing F# 5", + Content = "F# 5 is the latest version of F#, the functional programming language..." + }, + } + }); + + context.SaveChanges(); + } + } + + #region Model + public class Blog + { + public int Id { get; set; } + + public string Name { get; set; } + + public IList Posts { get; } = new List(); + } + + public class Post + { + public int Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + public int? BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + + public class BlogsContext : DbContext + { + private readonly bool _quiet; + + public BlogsContext(bool quiet = false) + { + _quiet = quiet; + } + + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog) + .OnDelete(DeleteBehavior.ClientSetNull); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .EnableSensitiveDataLogging() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Scratch;ConnectRetryCount=0"); + //.UseSqlite("DataSource=test.db"); + + if (!_quiet) + { + optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }); + } + } + } +} diff --git a/samples/core/CascadeDeletes/IntroRequiredSamples.cs b/samples/core/CascadeDeletes/IntroRequiredSamples.cs new file mode 100644 index 0000000000..e1740aac42 --- /dev/null +++ b/samples/core/CascadeDeletes/IntroRequiredSamples.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace IntroRequired +{ + public static class IntroRequiredSamples + { + public static void Deleting_principal_parent_1() + { + Console.WriteLine($">>>> Sample: {nameof(Deleting_principal_parent_1)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Deleting_principal_parent_1 + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + context.Remove(blog); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Severing_a_relationship_1() + { + Console.WriteLine($">>>> Sample: {nameof(Severing_a_relationship_1)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Severing_a_relationship_1 + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + foreach (var post in blog.Posts) + { + post.Blog = null; + } + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Severing_a_relationship_2() + { + Console.WriteLine($">>>> Sample: {nameof(Severing_a_relationship_2)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Severing_a_relationship_2 + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).Include(e => e.Posts).First(); + + blog.Posts.Clear(); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Where_cascading_behaviors_happen_1() + { + Console.WriteLine($">>>> Sample: {nameof(Where_cascading_behaviors_happen_1)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Where_cascading_behaviors_happen_1 + using var context = new BlogsContext(); + + var blog = context.Blogs.OrderBy(e => e.Name).First(); + + context.Remove(blog); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + } + + public static class Helpers + { + public static void RecreateCleanDatabase() + { + using var context = new BlogsContext(quiet: true); + + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + + public static void PopulateDatabase() + { + using var context = new BlogsContext(quiet: true); + + context.Add( + new Blog + { + Name = ".NET Blog", + Posts = + { + new Post + { + Title = "Announcing the Release of EF Core 5.0", + Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..." + }, + new Post + { + Title = "Announcing F# 5", + Content = "F# 5 is the latest version of F#, the functional programming language..." + }, + } + }); + + context.SaveChanges(); + } + } + + #region Model + public class Blog + { + public int Id { get; set; } + + public string Name { get; set; } + + public IList Posts { get; } = new List(); + } + + public class Post + { + public int Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + public int BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + + public class BlogsContext : DbContext + { + private readonly bool _quiet; + + public BlogsContext(bool quiet = false) + { + _quiet = quiet; + } + + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasMany(e => e.Posts).WithOne(e => e.Blog); //.OnDelete(DeleteBehavior.ClientSetNull); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .EnableSensitiveDataLogging() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Scratch;ConnectRetryCount=0"); + //.UseSqlite("DataSource=test.db"); + + if (!_quiet) + { + optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }); + } + } + } +} diff --git a/samples/core/CascadeDeletes/OptionalDependentsSamples.cs b/samples/core/CascadeDeletes/OptionalDependentsSamples.cs new file mode 100644 index 0000000000..f11ead4733 --- /dev/null +++ b/samples/core/CascadeDeletes/OptionalDependentsSamples.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Optional +{ + public static class OptionalDependentsSamples + { + public static void Optional_relationship_with_dependents_children_loaded() + { + Console.WriteLine("#### Optional relationship with dependents/children loaded"); + Console.WriteLine(); + + var deleteResults = Helpers.GatherData(c => c.Remove(c.Blogs.Include(e => e.Posts).Single())); + var severResults = Helpers.GatherData(c => c.Blogs.Include(e => e.Posts).Single().Posts.Clear()); + + Console.WriteLine($"| `{"DeleteBehavior".PadRight(16)} | {"On deleting principal/parent".PadRight(40)} | On severing from principal/parent"); + Console.WriteLine("|:------------------|------------------------------------------|----------------------------------------"); + foreach (var deleteBehavior in DeleteBehaviors) + { + Console.WriteLine($"| `{(deleteBehavior + "`").PadRight(16)} | {deleteResults[deleteBehavior].PadRight(40)} | {severResults[deleteBehavior]}"); + } + + Console.WriteLine(); + } + + public static void Optional_relationship_with_dependents_children_not_loaded() + { + Console.WriteLine("#### Optional relationship with dependents/children not loaded"); + Console.WriteLine(); + + var deleteResults = Helpers.GatherData(c => c.Remove(c.Blogs.Single())); + + Console.WriteLine($"| `{"DeleteBehavior".PadRight(16)} | {"On deleting principal/parent".PadRight(40)} | On severing from principal/parent"); + Console.WriteLine("|:------------------|------------------------------------------|----------------------------------------"); + foreach (var deleteBehavior in DeleteBehaviors) + { + Console.WriteLine($"| `{(deleteBehavior + "`").PadRight(16)} | {deleteResults[deleteBehavior].PadRight(40)} | N/A"); + } + + Console.WriteLine(); + } + + public static DeleteBehavior[] DeleteBehaviors { get; } + = + { + DeleteBehavior.Cascade, + DeleteBehavior.Restrict, + DeleteBehavior.NoAction, + DeleteBehavior.SetNull, + DeleteBehavior.ClientSetNull, + DeleteBehavior.ClientCascade, + DeleteBehavior.ClientNoAction + }; + + #region Model + public class Blog + { + public int Id { get; set; } + + public string Name { get; set; } + + public IList Posts { get; } = new List(); + } + + public class Post + { + public int Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + #region NullableBlogId + public int? BlogId { get; set; } + #endregion + + public Blog Blog { get; set; } + } + #endregion + + public static class Helpers + { + public static void RecreateCleanDatabase(OptionalBlogsContext context) + { + using (context) + { + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + } + + public static void PopulateDatabase(OptionalBlogsContext context) + { + using (context) + { + context.Add( + new Blog + { + Name = ".NET Blog", + Posts = + { + new Post + { + Title = "Announcing the Release of EF Core 5.0", + Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..." + }, + new Post + { + Title = "Announcing F# 5", + Content = "F# 5 is the latest version of F#, the functional programming language..." + }, + } + }); + + context.SaveChanges(); + } + } + + public static Dictionary GatherData(Action action) + { + var results = new Dictionary(); + + foreach (var deleteBehavior in DeleteBehaviors) + { + RecreateCleanDatabase(new OptionalBlogsContext(deleteBehavior)); + PopulateDatabase(new OptionalBlogsContext(deleteBehavior)); + + try + { + using var context = new OptionalBlogsContext(deleteBehavior); + + action(context); + + context.ChangeTracker.DetectChanges(); + + var deletingPosts = context.ChangeTracker.Entries().Any(e => e.State == EntityState.Deleted); + var settingFksToNull = context.ChangeTracker.Entries().Any(e => e.State == EntityState.Modified); + + context.SaveChanges(); + + var deletedPosts = !context.Posts.AsNoTracking().Any(); + + results[deleteBehavior] = + deletingPosts + ? "Dependents deleted by EF Core" + : deletedPosts + ? "Dependents deleted by database" + : settingFksToNull + ? "Dependent FKs set to null by EF Core" + : "Dependent FKs set to null by database"; + } + catch (Exception e) + { + results[deleteBehavior] = $"`{e.GetType().Name}`"; + } + } + + return results; + } + } + + public class OptionalBlogsContext : DbContext + { + private readonly DeleteBehavior _deleteBehavior; + private readonly bool _quiet; + + public OptionalBlogsContext(DeleteBehavior deleteBehavior, bool quiet = true) + { + _deleteBehavior = deleteBehavior; + _quiet = quiet; + } + + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog) + .OnDelete(_deleteBehavior); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .EnableServiceProviderCaching(false) + .EnableSensitiveDataLogging() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Scratch;ConnectRetryCount=0"); + //.UseSqlite("DataSource=test.db"); + + if (!_quiet) + { + optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }); + } + } + } + } +} diff --git a/samples/core/CascadeDeletes/Program.cs b/samples/core/CascadeDeletes/Program.cs new file mode 100644 index 0000000000..8711db7b5e --- /dev/null +++ b/samples/core/CascadeDeletes/Program.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using DatabaseCycles; +using IntroOptional; +using IntroRequired; +using Optional; +using Required; + +public class Program +{ + public static void Main() + { + Console.WriteLine("Samples for _Cascade Delete_"); + Console.WriteLine(); + + IntroRequiredSamples.Deleting_principal_parent_1(); + IntroRequiredSamples.Severing_a_relationship_1(); + IntroRequiredSamples.Severing_a_relationship_2(); + IntroRequiredSamples.Where_cascading_behaviors_happen_1(); + + WithDatabaseCycleSamples.Database_cascade_limitations_1(); + WithDatabaseCycleSamples.Database_cascade_limitations_2(); + + IntroOptionalSamples.Deleting_principal_parent_1b(); + IntroOptionalSamples.Severing_a_relationship_1b(); + IntroOptionalSamples.Severing_a_relationship_2b(); + + RequiredDependentsSamples.Required_relationship_with_dependents_children_loaded(); + RequiredDependentsSamples.Required_relationship_with_dependents_children_not_loaded(); + OptionalDependentsSamples.Optional_relationship_with_dependents_children_loaded(); + OptionalDependentsSamples.Optional_relationship_with_dependents_children_not_loaded(); + } +} diff --git a/samples/core/CascadeDeletes/RequiredDependentsSamples.cs b/samples/core/CascadeDeletes/RequiredDependentsSamples.cs new file mode 100644 index 0000000000..4815e26dab --- /dev/null +++ b/samples/core/CascadeDeletes/RequiredDependentsSamples.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Required +{ + public static class RequiredDependentsSamples + { + public static void Required_relationship_with_dependents_children_loaded() + { + Console.WriteLine("#### Required relationship with dependents/children loaded"); + Console.WriteLine(); + + var deleteResults = Helpers.GatherData(c => c.Remove(c.Blogs.Include(e => e.Posts).Single())); + var severResults = Helpers.GatherData(c => c.Blogs.Include(e => e.Posts).Single().Posts.Clear()); + + Console.WriteLine($"| `{"DeleteBehavior".PadRight(16)} | {"On deleting principal/parent".PadRight(40)} | On severing from principal/parent"); + Console.WriteLine("|:------------------|------------------------------------------|----------------------------------------"); + foreach (var deleteBehavior in DeleteBehaviors) + { + Console.WriteLine($"| `{(deleteBehavior + "`").PadRight(16)} | {deleteResults[deleteBehavior].PadRight(40)} | {severResults[deleteBehavior]}"); + } + + Console.WriteLine(); + } + + public static void Required_relationship_with_dependents_children_not_loaded() + { + Console.WriteLine("#### Required relationship with dependents/children not loaded"); + Console.WriteLine(); + + var deleteResults = Helpers.GatherData(c => c.Remove(c.Blogs.Single())); + + Console.WriteLine($"| `{"DeleteBehavior".PadRight(16)} | {"On deleting principal/parent".PadRight(40)} | On severing from principal/parent"); + Console.WriteLine("|:------------------|------------------------------------------|----------------------------------------"); + foreach (var deleteBehavior in DeleteBehaviors) + { + Console.WriteLine($"| `{(deleteBehavior + "`").PadRight(16)} | {deleteResults[deleteBehavior].PadRight(40)} | N/A"); + } + + Console.WriteLine(); + } + + public static DeleteBehavior[] DeleteBehaviors { get; } + = + { + DeleteBehavior.Cascade, + DeleteBehavior.Restrict, + DeleteBehavior.NoAction, + DeleteBehavior.SetNull, + DeleteBehavior.ClientSetNull, + DeleteBehavior.ClientCascade, + DeleteBehavior.ClientNoAction + }; + + #region Model + public class Blog + { + public int Id { get; set; } + + public string Name { get; set; } + + public IList Posts { get; } = new List(); + } + + public class Post + { + public int Id { get; set; } + + public string Title { get; set; } + public string Content { get; set; } + + public int BlogId { get; set; } + public Blog Blog { get; set; } + } + #endregion + + public static class Helpers + { + public static void RecreateCleanDatabase(RequiredBlogsContext context) + { + using (context) + { + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + } + + public static void PopulateDatabase(RequiredBlogsContext context) + { + using (context) + { + context.Add( + new Blog + { + Name = ".NET Blog", + Posts = + { + new Post + { + Title = "Announcing the Release of EF Core 5.0", + Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..." + }, + new Post + { + Title = "Announcing F# 5", + Content = "F# 5 is the latest version of F#, the functional programming language..." + }, + } + }); + + context.SaveChanges(); + } + } + + public static Dictionary GatherData(Action action) + { + var results = new Dictionary(); + + foreach (var deleteBehavior in DeleteBehaviors) + { + try + { + RecreateCleanDatabase(new RequiredBlogsContext(deleteBehavior)); + PopulateDatabase(new RequiredBlogsContext(deleteBehavior)); + } + catch (Exception e) + { + results[deleteBehavior] = $"`{e.GetType().Name}`"; + continue; + } + + try + { + using var context = new RequiredBlogsContext(deleteBehavior); + + action(context); + + context.ChangeTracker.DetectChanges(); + + var deletingPosts = context.ChangeTracker.Entries().Any(e => e.State == EntityState.Deleted); + var settingFksToNull = context.ChangeTracker.Entries().Any(e => e.State == EntityState.Modified); + + context.SaveChanges(); + + var deletedPosts = !context.Posts.AsNoTracking().Any(); + + results[deleteBehavior] = + deletingPosts + ? "Dependents deleted by EF Core" + : deletedPosts + ? "Dependents deleted by database" + : settingFksToNull + ? "Dependent FKs set to null by EF Core" + : "Dependent FKs set to null by database"; + } + catch (Exception e) + { + results[deleteBehavior] = $"`{e.GetType().Name}`"; + } + } + + return results; + } + } + + public class RequiredBlogsContext : DbContext + { + private readonly DeleteBehavior _deleteBehavior; + private readonly bool _quiet; + + public RequiredBlogsContext(DeleteBehavior deleteBehavior, bool quiet = true) + { + _deleteBehavior = deleteBehavior; + _quiet = quiet; + } + + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasMany(e => e.Posts) + .WithOne(e => e.Blog) + .OnDelete(_deleteBehavior); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .EnableServiceProviderCaching(false) + .EnableSensitiveDataLogging() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Scratch;ConnectRetryCount=0"); + //.UseSqlite("DataSource=test.db"); + + if (!_quiet) + { + optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }); + } + } + } + } +} diff --git a/samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs b/samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs new file mode 100644 index 0000000000..2880189b66 --- /dev/null +++ b/samples/core/CascadeDeletes/WithDatabaseCycleSamples.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace DatabaseCycles +{ + public static class WithDatabaseCycleSamples + { + public static void Database_cascade_limitations_1() + { + Console.WriteLine($">>>> Sample: {nameof(Database_cascade_limitations_1)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + #region Database_cascade_limitations_1 + using var context = new BlogsContext(); + + var owner = context.People.Single(e => e.Name == "ajcvickers"); + var blog = context.Blogs.Single(e => e.Owner == owner); + + context.Remove(owner); + + context.SaveChanges(); + #endregion + + Console.WriteLine(); + } + + public static void Database_cascade_limitations_2() + { + Console.WriteLine($">>>> Sample: {nameof(Database_cascade_limitations_2)}"); + Console.WriteLine(); + + Helpers.RecreateCleanDatabase(); + Helpers.PopulateDatabase(); + + try + { + #region Database_cascade_limitations_2 + using var context = new BlogsContext(); + + var owner = context.People.Single(e => e.Name == "ajcvickers"); + + context.Remove(owner); + + context.SaveChanges(); + #endregion + } + catch (Exception e) + { + Console.WriteLine($"{e.GetType().FullName}: {e.Message}"); + if (e.InnerException != null) + { + Console.WriteLine($"{e.InnerException.GetType().FullName}: {e.InnerException.Message}"); + } + } + + Console.WriteLine(); + } + } + + public static class Helpers + { + public static void RecreateCleanDatabase() + { + using var context = new BlogsContext(quiet: true); + + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + } + + public static void PopulateDatabase() + { + using var context = new BlogsContext(quiet: true); + + var person = new Person + { + Name = "ajcvickers" + }; + + context.Add( + new Blog + { + Owner = person, + Name = ".NET Blog", + Posts = + { + new Post + { + Title = "Announcing the Release of EF Core 5.0", + Content = "Announcing the release of EF Core 5.0, a full featured cross-platform...", + Author = person + }, + new Post + { + Title = "Announcing F# 5", + Content = "F# 5 is the latest version of F#, the functional programming language...", + Author = person + }, + } + }); + + context.SaveChanges(); + } + } + + #region Model + public class Blog + { + public int Id { get; set; } + public string Name { get; set; } + + public IList Posts { get; } = new List(); + + public int OwnerId { get; set; } + public Person Owner { get; set; } + } + + public class Post + { + public int Id { get; set; } + public string Title { get; set; } + public string Content { get; set; } + + public int BlogId { get; set; } + public Blog Blog { get; set; } + + public int AuthorId { get; set; } + public Person Author { get; set; } + } + + public class Person + { + public int Id { get; set; } + public string Name { get; set; } + + public IList Posts { get; } = new List(); + + public Blog OwnedBlog { get; set; } + } + #endregion + + public class BlogsContext : DbContext + { + private readonly bool _quiet; + + public BlogsContext(bool quiet = false) + { + _quiet = quiet; + } + + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + public DbSet People { get; set; } + + #region OnModelCreating + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasOne(e => e.Owner) + .WithOne(e => e.OwnedBlog) + .OnDelete(DeleteBehavior.ClientCascade); + } + #endregion + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .EnableSensitiveDataLogging() + .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=Scratch;ConnectRetryCount=0"); + //.UseSqlite("DataSource=test.db"); + + if (!_quiet) + { + optionsBuilder.LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted }); + } + } + } +} diff --git a/samples/core/Samples.sln b/samples/core/Samples.sln index 89ebd01f37..10a27ea9e5 100644 --- a/samples/core/Samples.sln +++ b/samples/core/Samples.sln @@ -151,6 +151,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChangeTrackerDebugging", "C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NullSemantics", "Querying\NullSemantics\NullSemantics.csproj", "{A241ED91-DE41-4310-B7CD-802F4304F27D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CascadeDeletes", "CascadeDeletes\CascadeDeletes.csproj", "{1F72C1AD-D6E1-4F06-B0C7-B04B57DA22B6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -413,6 +415,10 @@ Global {A241ED91-DE41-4310-B7CD-802F4304F27D}.Debug|Any CPU.Build.0 = Debug|Any CPU {A241ED91-DE41-4310-B7CD-802F4304F27D}.Release|Any CPU.ActiveCfg = Release|Any CPU {A241ED91-DE41-4310-B7CD-802F4304F27D}.Release|Any CPU.Build.0 = Release|Any CPU + {1F72C1AD-D6E1-4F06-B0C7-B04B57DA22B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F72C1AD-D6E1-4F06-B0C7-B04B57DA22B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F72C1AD-D6E1-4F06-B0C7-B04B57DA22B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F72C1AD-D6E1-4F06-B0C7-B04B57DA22B6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE