From c008d05b606f11ef98f8be6baa6e764fdea49091 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 26 Oct 2022 01:17:16 +0200 Subject: [PATCH] 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. --- .../Relationships/Optional/Blog.cs | 19 +++ .../Optional/BloggingDbContext.cs | 93 +++++++++++++ .../Relationships/Optional/Person.cs | 19 +++ .../Relationships/Optional/Post.cs | 22 +++ .../Relationships/Optional/SetNullTests.cs | 130 ++++++++++++++++++ .../Relationships/Required/Blog.cs | 19 +++ .../Required/BloggingDbContext.cs | 93 +++++++++++++ .../Relationships/Required/CascadeTests.cs | 130 ++++++++++++++++++ .../Relationships/Required/Person.cs | 19 +++ .../Relationships/Required/Post.cs | 22 +++ 10 files changed, 566 insertions(+) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Blog.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/BloggingDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Person.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Post.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/SetNullTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Blog.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/BloggingDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/CascadeTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Person.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Post.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Blog.cs new file mode 100644 index 0000000000..352fc30ae3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Blog.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")] +public sealed class Blog : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public IList Posts { get; } = new List(); + + [HasOne] + public Person? Owner { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/BloggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/BloggingDbContext.cs new file mode 100644 index 0000000000..6bdb7f777a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/BloggingDbContext.cs @@ -0,0 +1,93 @@ +//#define HANDLE_CLIENT_SIDE + +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public class BloggingDbContext : DbContext +{ + public DbSet Blogs => Set(); + public DbSet Posts => Set(); + public DbSet People => Set(); + + public BloggingDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(post => post.Blog) + .WithMany(blog => blog.Posts) + .HasForeignKey("BlogId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientSetNull) +#else + .OnDelete(DeleteBehavior.SetNull) +#endif + ; + + builder.Entity() + .HasOne(post => post.Author) + .WithMany(person => person.Posts) + .HasForeignKey("AuthorId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientSetNull) +#else + .OnDelete(DeleteBehavior.SetNull) +#endif + ; + + builder.Entity() + .HasOne(blog => blog.Owner) + .WithOne(person => person.OwnedBlog) + .HasForeignKey("OwnerId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientSetNull) +#else + .OnDelete(DeleteBehavior.SetNull) +#endif + ; + + // Generated SQL: + /* + + CREATE TABLE "People" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + CONSTRAINT "PK_People" PRIMARY KEY ("Id") + ); + + CREATE TABLE "Blogs" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "OwnerId" integer NULL, + CONSTRAINT "PK_Blogs" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Blogs_People_OwnerId" FOREIGN KEY ("OwnerId") REFERENCES "People" ("Id") ON DELETE SET NULL + ); + + CREATE TABLE "Posts" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Title" text NOT NULL, + "Content" text NOT NULL, + "BlogId" integer NULL, + "AuthorId" integer NULL, + CONSTRAINT "PK_Posts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Posts_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id") ON DELETE SET NULL, + CONSTRAINT "FK_Posts_People_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "People" ("Id") ON DELETE SET NULL + ); + + CREATE UNIQUE INDEX "IX_Blogs_OwnerId" ON "Blogs" ("OwnerId"); + + CREATE INDEX "IX_Posts_AuthorId" ON "Posts" ("AuthorId"); + + CREATE INDEX "IX_Posts_BlogId" ON "Posts" ("BlogId"); + + */ + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Person.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Person.cs new file mode 100644 index 0000000000..c152b032d8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Person.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")] +public sealed class Person : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public IList Posts { get; } = new List(); + + [HasOne] + public Blog? OwnedBlog { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Post.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Post.cs new file mode 100644 index 0000000000..e1ab61c3bf --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/Post.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional")] +public sealed class Post : Identifiable +{ + [Attr] + public string Title { get; set; } = null!; + + [Attr] + public string Content { get; set; } = null!; + + [HasOne] + public Blog? Blog { get; set; } + + [HasOne] + public Person? Author { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/SetNullTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/SetNullTests.cs new file mode 100644 index 0000000000..517379f65d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Optional/SetNullTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Optional; + +public sealed class SetNullTests : IClassFixture, BloggingDbContext>> +{ + private const string OwnerName = "Jack"; + private const string AuthorName = "Jull"; + + private readonly IntegrationTestContext, BloggingDbContext> _testContext; + + public SetNullTests(IntegrationTestContext, BloggingDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Deleting_a_blog_will_cause_the_blog_in_all_the_related_posts_to_become_null() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Blog blog = await dbContext.Blogs.SingleAsync(); + dbContext.Remove(blog); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().BeEmpty(); + dbContext.Posts.Should().ContainSingle(post => post.Blog == null); + dbContext.People.Should().HaveCount(2); + await Task.Yield(); + }); + } + + [Fact] + public async Task Deleting_the_author_of_posts_will_cause_the_author_of_authored_posts_to_become_null() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person author = await dbContext.People.SingleAsync(person => person.Name == AuthorName); + dbContext.Remove(author); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().HaveCount(1); + dbContext.Posts.Should().ContainSingle(post => post.Author == null); + dbContext.People.Should().ContainSingle(person => person.Name == OwnerName); + await Task.Yield(); + }); + } + + [Fact] + public async Task Deleting_the_owner_of_a_blog_will_cause_the_owner_of_blog_to_become_null() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person owner = await dbContext.People.SingleAsync(person => person.Name == OwnerName); + dbContext.Remove(owner); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().ContainSingle(blog => blog.Owner == null); + dbContext.Posts.Should().HaveCount(1); + dbContext.People.Should().ContainSingle(person => person.Name == AuthorName); + await Task.Yield(); + }); + } + + private async Task StoreTestDataAsync() + { + Post newPost = CreateTestData(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + + dbContext.Posts.Add(newPost); + await dbContext.SaveChangesAsync(); + }); + } + + private static Post CreateTestData() + { + return new Post + { + Title = "Cascading Deletes", + Content = "...", + Blog = new Blog + { + Name = "EF Core", + Owner = new Person + { + Name = OwnerName + } + }, + Author = new Person + { + Name = AuthorName + } + }; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Blog.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Blog.cs new file mode 100644 index 0000000000..78632fa174 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Blog.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required")] +public sealed class Blog : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public IList Posts { get; } = new List(); + + [HasOne] + public Person Owner { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/BloggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/BloggingDbContext.cs new file mode 100644 index 0000000000..9b1f7a8846 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/BloggingDbContext.cs @@ -0,0 +1,93 @@ +//#define HANDLE_CLIENT_SIDE + +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public class BloggingDbContext : DbContext +{ + public DbSet Blogs => Set(); + public DbSet Posts => Set(); + public DbSet People => Set(); + + public BloggingDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(post => post.Blog) + .WithMany(blog => blog.Posts) + .HasForeignKey("BlogId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientCascade) +#else + .OnDelete(DeleteBehavior.Cascade) +#endif + ; + + builder.Entity() + .HasOne(post => post.Author) + .WithMany(person => person.Posts) + .HasForeignKey("AuthorId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientCascade) +#else + .OnDelete(DeleteBehavior.Cascade) +#endif + ; + + builder.Entity() + .HasOne(blog => blog.Owner) + .WithOne(person => person.OwnedBlog) + .HasForeignKey("OwnerId") +#if HANDLE_CLIENT_SIDE + .OnDelete(DeleteBehavior.ClientCascade) +#else + .OnDelete(DeleteBehavior.Cascade) +#endif + ; + + // Generated SQL: + /* + + CREATE TABLE "People" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + CONSTRAINT "PK_People" PRIMARY KEY ("Id") + ); + + CREATE TABLE "Blogs" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Name" text NOT NULL, + "OwnerId" integer NOT NULL, + CONSTRAINT "PK_Blogs" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Blogs_People_OwnerId" FOREIGN KEY ("OwnerId") REFERENCES "People" ("Id") ON DELETE CASCADE + ); + + CREATE TABLE "Posts" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "Title" text NOT NULL, + "Content" text NOT NULL, + "BlogId" integer NOT NULL, + "AuthorId" integer NOT NULL, + CONSTRAINT "PK_Posts" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Posts_Blogs_BlogId" FOREIGN KEY ("BlogId") REFERENCES "Blogs" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_Posts_People_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "People" ("Id") ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX "IX_Blogs_OwnerId" ON "Blogs" ("OwnerId"); + + CREATE INDEX "IX_Posts_AuthorId" ON "Posts" ("AuthorId"); + + CREATE INDEX "IX_Posts_BlogId" ON "Posts" ("BlogId"); + + */ + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/CascadeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/CascadeTests.cs new file mode 100644 index 0000000000..edb5966300 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/CascadeTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required; + +public sealed class CascadeTests : IClassFixture, BloggingDbContext>> +{ + private const string OwnerName = "Jack"; + private const string AuthorName = "Jull"; + + private readonly IntegrationTestContext, BloggingDbContext> _testContext; + + public CascadeTests(IntegrationTestContext, BloggingDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Deleting_a_blog_will_cascade_delete_all_the_related_posts() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Blog blog = await dbContext.Blogs.SingleAsync(); + dbContext.Remove(blog); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().BeEmpty(); + dbContext.Posts.Should().BeEmpty(); + dbContext.People.Should().HaveCount(2); + await Task.Yield(); + }); + } + + [Fact] + public async Task Deleting_the_author_of_posts_will_cause_the_authored_posts_to_be_cascade_deleted() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person author = await dbContext.People.SingleAsync(person => person.Name == AuthorName); + dbContext.Remove(author); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().HaveCount(1); + dbContext.Posts.Should().BeEmpty(); + dbContext.People.Should().ContainSingle(person => person.Name == OwnerName); + await Task.Yield(); + }); + } + + [Fact] + public async Task Deleting_the_owner_of_a_blog_will_cause_the_blog_to_be_cascade_deleted() + { + // Arrange + await StoreTestDataAsync(); + + // Act + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person owner = await dbContext.People.SingleAsync(person => person.Name == OwnerName); + dbContext.Remove(owner); + + await dbContext.SaveChangesAsync(); + }); + + // Assert + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Should().BeEmpty(); + dbContext.Posts.Should().BeEmpty(); + dbContext.People.Should().ContainSingle(person => person.Name == AuthorName); + await Task.Yield(); + }); + } + + private async Task StoreTestDataAsync() + { + Post newPost = CreateTestData(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); + + dbContext.Posts.Add(newPost); + await dbContext.SaveChangesAsync(); + }); + } + + private static Post CreateTestData() + { + return new Post + { + Title = "Cascading Deletes", + Content = "...", + Blog = new Blog + { + Name = "EF Core", + Owner = new Person + { + Name = OwnerName + } + }, + Author = new Person + { + Name = AuthorName + } + }; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Person.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Person.cs new file mode 100644 index 0000000000..56f6ca66e1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Person.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required")] +public sealed class Person : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public IList Posts { get; } = new List(); + + [HasOne] + public Blog OwnedBlog { get; set; } = null!; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Post.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Post.cs new file mode 100644 index 0000000000..2af5f009d5 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/DeleteBehaviors/Relationships/Required/Post.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.DeleteBehaviors.Relationships.Required")] +public sealed class Post : Identifiable +{ + [Attr] + public string Title { get; set; } = null!; + + [Attr] + public string Content { get; set; } = null!; + + [HasOne] + public Blog Blog { get; set; } = null!; + + [HasOne] + public Person Author { get; set; } = null!; +}