Skip to content

Commit 7517f30

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 prove that PostgreSQL handles the presented scenario successfully. Letting the database handle cascading is a lot more efficient.
1 parent af46168 commit 7517f30

File tree

10 files changed

+568
-0
lines changed

10 files changed

+568
-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,95 @@
1+
//#define HANDLE_CLIENT_SIDE
2+
3+
using System.Diagnostics;
4+
using JetBrains.Annotations;
5+
using Microsoft.EntityFrameworkCore;
6+
using Microsoft.Extensions.Logging;
7+
8+
// @formatter:wrap_chained_method_calls chop_always
9+
10+
namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional;
11+
12+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
13+
public class BloggingDbContext : DbContext
14+
{
15+
public DbSet<Blog> Blogs => Set<Blog>();
16+
public DbSet<Post> Posts => Set<Post>();
17+
public DbSet<Person> People => Set<Person>();
18+
19+
public BloggingDbContext(DbContextOptions<BloggingDbContext> options)
20+
: base(options)
21+
{
22+
}
23+
24+
protected override void OnModelCreating(ModelBuilder builder)
25+
{
26+
builder.Entity<Post>()
27+
.HasOne(post => post.Blog)
28+
.WithMany(blog => blog.Posts)
29+
.HasForeignKey("BlogId")
30+
#if HANDLE_CLIENT_SIDE
31+
.OnDelete(DeleteBehavior.ClientSetNull)
32+
#else
33+
.OnDelete(DeleteBehavior.SetNull)
34+
#endif
35+
;
36+
37+
builder.Entity<Post>()
38+
.HasOne(post => post.Author)
39+
.WithMany(person => person.Posts)
40+
.HasForeignKey("AuthorId")
41+
#if HANDLE_CLIENT_SIDE
42+
.OnDelete(DeleteBehavior.ClientSetNull)
43+
#else
44+
.OnDelete(DeleteBehavior.SetNull)
45+
#endif
46+
;
47+
48+
builder.Entity<Blog>()
49+
.HasOne(blog => blog.Owner)
50+
.WithOne(person => person.OwnedBlog)
51+
.HasForeignKey<Blog>("OwnerId")
52+
#if HANDLE_CLIENT_SIDE
53+
.OnDelete(DeleteBehavior.ClientSetNull)
54+
#else
55+
.OnDelete(DeleteBehavior.SetNull)
56+
#endif
57+
;
58+
59+
// Generated SQL:
60+
/*
61+
62+
CREATE TABLE "People" (
63+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
64+
"Name" text NOT NULL,
65+
CONSTRAINT "PK_People" PRIMARY KEY ("Id")
66+
);
67+
68+
CREATE TABLE "Blogs" (
69+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
70+
"Name" text NOT NULL,
71+
"OwnerId" integer NULL,
72+
CONSTRAINT "PK_Blogs" PRIMARY KEY ("Id"),
73+
CONSTRAINT "FK_Blogs_People_OwnerId" FOREIGN KEY ("OwnerId") REFERENCES "People" ("Id") ON DELETE SET NULL
74+
);
75+
76+
CREATE TABLE "Posts" (
77+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
78+
"Title" text NOT NULL,
79+
"Content" text NOT NULL,
80+
"BlogId" integer NULL,
81+
"AuthorId" integer NULL,
82+
CONSTRAINT "PK_Posts" PRIMARY KEY ("Id"),
83+
CONSTRAINT "FK_Posts_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id") ON DELETE SET NULL,
84+
CONSTRAINT "FK_Posts_People_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "People" ("Id") ON DELETE SET NULL
85+
);
86+
87+
CREATE UNIQUE INDEX "IX_Blogs_OwnerId" ON "Blogs" ("OwnerId");
88+
89+
CREATE INDEX "IX_Posts_AuthorId" ON "Posts" ("AuthorId");
90+
91+
CREATE INDEX "IX_Posts_BlogId" ON "Posts" ("BlogId");
92+
93+
*/
94+
}
95+
}
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)