Skip to content

Commit c008d05

Browse files
committed
Added model and tests that represent the scenario described at https://learn.microsoft.com/en-us/ef/core/saving/cascade-delete#database-cascade-limitations
The article states various limitations in SQL Server: > Some databases, most notably SQL Server, have limitations on the cascade behaviors that form cycles. > Using cascading deletes and cascading nulls in the database at the same time will almost always result in relationship cycles when using SQL Server. When all related entities are loaded in the EF Core change tracker upfront, EF Core is able to handle the scenario by issuing multiple SQL statements. And that's why the default delete behavior for optional relationships is `ClientSetNull` instead of `SetNull`. The tests in this commit show that PostgreSQL handles the presented scenario successfully. Letting the database handle cascading is a lot more efficient.
1 parent af46168 commit c008d05

File tree

10 files changed

+566
-0
lines changed

10 files changed

+566
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")]
9+
public sealed class Blog : Identifiable<int>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasMany]
15+
public IList<Post> Posts { get; } = new List<Post>();
16+
17+
[HasOne]
18+
public Person? Owner { get; set; }
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//#define HANDLE_CLIENT_SIDE
2+
3+
using JetBrains.Annotations;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
// @formatter:wrap_chained_method_calls chop_always
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
9+
10+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
11+
public class BloggingDbContext : DbContext
12+
{
13+
public DbSet<Blog> Blogs => Set<Blog>();
14+
public DbSet<Post> Posts => Set<Post>();
15+
public DbSet<Person> People => Set<Person>();
16+
17+
public BloggingDbContext(DbContextOptions<BloggingDbContext> options)
18+
: base(options)
19+
{
20+
}
21+
22+
protected override void OnModelCreating(ModelBuilder builder)
23+
{
24+
builder.Entity<Post>()
25+
.HasOne(post => post.Blog)
26+
.WithMany(blog => blog.Posts)
27+
.HasForeignKey("BlogId")
28+
#if HANDLE_CLIENT_SIDE
29+
.OnDelete(DeleteBehavior.ClientSetNull)
30+
#else
31+
.OnDelete(DeleteBehavior.SetNull)
32+
#endif
33+
;
34+
35+
builder.Entity<Post>()
36+
.HasOne(post => post.Author)
37+
.WithMany(person => person.Posts)
38+
.HasForeignKey("AuthorId")
39+
#if HANDLE_CLIENT_SIDE
40+
.OnDelete(DeleteBehavior.ClientSetNull)
41+
#else
42+
.OnDelete(DeleteBehavior.SetNull)
43+
#endif
44+
;
45+
46+
builder.Entity<Blog>()
47+
.HasOne(blog => blog.Owner)
48+
.WithOne(person => person.OwnedBlog)
49+
.HasForeignKey<Blog>("OwnerId")
50+
#if HANDLE_CLIENT_SIDE
51+
.OnDelete(DeleteBehavior.ClientSetNull)
52+
#else
53+
.OnDelete(DeleteBehavior.SetNull)
54+
#endif
55+
;
56+
57+
// Generated SQL:
58+
/*
59+
60+
CREATE TABLE "People" (
61+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
62+
"Name" text NOT NULL,
63+
CONSTRAINT "PK_People" PRIMARY KEY ("Id")
64+
);
65+
66+
CREATE TABLE "Blogs" (
67+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
68+
"Name" text NOT NULL,
69+
"OwnerId" integer NULL,
70+
CONSTRAINT "PK_Blogs" PRIMARY KEY ("Id"),
71+
CONSTRAINT "FK_Blogs_People_OwnerId" FOREIGN KEY ("OwnerId") REFERENCES "People" ("Id") ON DELETE SET NULL
72+
);
73+
74+
CREATE TABLE "Posts" (
75+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
76+
"Title" text NOT NULL,
77+
"Content" text NOT NULL,
78+
"BlogId" integer NULL,
79+
"AuthorId" integer NULL,
80+
CONSTRAINT "PK_Posts" PRIMARY KEY ("Id"),
81+
CONSTRAINT "FK_Posts_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id") ON DELETE SET NULL,
82+
CONSTRAINT "FK_Posts_People_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "People" ("Id") ON DELETE SET NULL
83+
);
84+
85+
CREATE UNIQUE INDEX "IX_Blogs_OwnerId" ON "Blogs" ("OwnerId");
86+
87+
CREATE INDEX "IX_Posts_AuthorId" ON "Posts" ("AuthorId");
88+
89+
CREATE INDEX "IX_Posts_BlogId" ON "Posts" ("BlogId");
90+
91+
*/
92+
}
93+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")]
9+
public sealed class Person : Identifiable<int>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasMany]
15+
public IList<Post> Posts { get; } = new List<Post>();
16+
17+
[HasOne]
18+
public Blog? OwnedBlog { get; set; }
19+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")]
9+
public sealed class Post : Identifiable<int>
10+
{
11+
[Attr]
12+
public string Title { get; set; } = null!;
13+
14+
[Attr]
15+
public string Content { get; set; } = null!;
16+
17+
[HasOne]
18+
public Blog? Blog { get; set; }
19+
20+
[HasOne]
21+
public Person? Author { get; set; }
22+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using FluentAssertions;
2+
using Microsoft.EntityFrameworkCore;
3+
using TestBuildingBlocks;
4+
using Xunit;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
7+
8+
public sealed class SetNullTests : IClassFixture<IntegrationTestContext<TestableStartup<BloggingDbContext>, BloggingDbContext>>
9+
{
10+
private const string OwnerName = "Jack";
11+
private const string AuthorName = "Jull";
12+
13+
private readonly IntegrationTestContext<TestableStartup<BloggingDbContext>, BloggingDbContext> _testContext;
14+
15+
public SetNullTests(IntegrationTestContext<TestableStartup<BloggingDbContext>, BloggingDbContext> testContext)
16+
{
17+
_testContext = testContext;
18+
}
19+
20+
[Fact]
21+
public async Task Deleting_a_blog_will_cause_the_blog_in_all_the_related_posts_to_become_null()
22+
{
23+
// Arrange
24+
await StoreTestDataAsync();
25+
26+
// Act
27+
await _testContext.RunOnDatabaseAsync(async dbContext =>
28+
{
29+
Blog blog = await dbContext.Blogs.SingleAsync();
30+
dbContext.Remove(blog);
31+
32+
await dbContext.SaveChangesAsync();
33+
});
34+
35+
// Assert
36+
await _testContext.RunOnDatabaseAsync(async dbContext =>
37+
{
38+
dbContext.Blogs.Should().BeEmpty();
39+
dbContext.Posts.Should().ContainSingle(post => post.Blog == null);
40+
dbContext.People.Should().HaveCount(2);
41+
await Task.Yield();
42+
});
43+
}
44+
45+
[Fact]
46+
public async Task Deleting_the_author_of_posts_will_cause_the_author_of_authored_posts_to_become_null()
47+
{
48+
// Arrange
49+
await StoreTestDataAsync();
50+
51+
// Act
52+
await _testContext.RunOnDatabaseAsync(async dbContext =>
53+
{
54+
Person author = await dbContext.People.SingleAsync(person => person.Name == AuthorName);
55+
dbContext.Remove(author);
56+
57+
await dbContext.SaveChangesAsync();
58+
});
59+
60+
// Assert
61+
await _testContext.RunOnDatabaseAsync(async dbContext =>
62+
{
63+
dbContext.Blogs.Should().HaveCount(1);
64+
dbContext.Posts.Should().ContainSingle(post => post.Author == null);
65+
dbContext.People.Should().ContainSingle(person => person.Name == OwnerName);
66+
await Task.Yield();
67+
});
68+
}
69+
70+
[Fact]
71+
public async Task Deleting_the_owner_of_a_blog_will_cause_the_owner_of_blog_to_become_null()
72+
{
73+
// Arrange
74+
await StoreTestDataAsync();
75+
76+
// Act
77+
await _testContext.RunOnDatabaseAsync(async dbContext =>
78+
{
79+
Person owner = await dbContext.People.SingleAsync(person => person.Name == OwnerName);
80+
dbContext.Remove(owner);
81+
82+
await dbContext.SaveChangesAsync();
83+
});
84+
85+
// Assert
86+
await _testContext.RunOnDatabaseAsync(async dbContext =>
87+
{
88+
dbContext.Blogs.Should().ContainSingle(blog => blog.Owner == null);
89+
dbContext.Posts.Should().HaveCount(1);
90+
dbContext.People.Should().ContainSingle(person => person.Name == AuthorName);
91+
await Task.Yield();
92+
});
93+
}
94+
95+
private async Task StoreTestDataAsync()
96+
{
97+
Post newPost = CreateTestData();
98+
99+
await _testContext.RunOnDatabaseAsync(async dbContext =>
100+
{
101+
await dbContext.ClearTableAsync<Blog>();
102+
await dbContext.ClearTableAsync<Post>();
103+
await dbContext.ClearTableAsync<Person>();
104+
105+
dbContext.Posts.Add(newPost);
106+
await dbContext.SaveChangesAsync();
107+
});
108+
}
109+
110+
private static Post CreateTestData()
111+
{
112+
return new Post
113+
{
114+
Title = "Cascading Deletes",
115+
Content = "...",
116+
Blog = new Blog
117+
{
118+
Name = "EF Core",
119+
Owner = new Person
120+
{
121+
Name = OwnerName
122+
}
123+
},
124+
Author = new Person
125+
{
126+
Name = AuthorName
127+
}
128+
};
129+
}
130+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required")]
9+
public sealed class Blog : Identifiable<int>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasMany]
15+
public IList<Post> Posts { get; } = new List<Post>();
16+
17+
[HasOne]
18+
public Person Owner { get; set; } = null!;
19+
}

0 commit comments

Comments
 (0)