diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index d1f36a89a9..1787bbd8ac 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -3,7 +3,7 @@ A relationship is a named link between two resource types, including a direction. They are similar to [navigation properties in Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/modeling/relationships). -Relationships come in three flavors: to-one, to-many and many-to-many. +Relationships come in two flavors: to-one and to-many. The left side of a relationship is where the relationship is declared, the right side is the resource type it points to. ## HasOne @@ -22,10 +22,14 @@ public class TodoItem : Identifiable The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). -### Required one-to-one relationships in Entity Framework Core +### One-to-one relationships in Entity Framework Core -By default, Entity Framework Core generates an identifying foreign key for a required 1-to-1 relationship. -This means no foreign key column is generated, instead the primary keys point to each other directly. +By default, Entity Framework Core tries to generate an *identifying foreign key* for a one-to-one relationship whenever possible. +In that case, no foreign key column is generated. Instead the primary keys point to each other directly. + +**That mechanism does not make sense for JSON:API, because patching a relationship would result in also +changing the identity of a resource. Naming the foreign key explicitly fixes the problem, which enforces +to create a foreign key column.** The next example defines that each car requires an engine, while an engine is optionally linked to a car. @@ -51,18 +55,19 @@ public sealed class AppDbContext : DbContext builder.Entity() .HasOne(car => car.Engine) .WithOne(engine => engine.Car) - .HasForeignKey() - .IsRequired(); + .HasForeignKey(); } } ``` Which results in Entity Framework Core generating the next database objects: + ```sql CREATE TABLE "Engine" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") ); + CREATE TABLE "Cars" ( "Id" integer NOT NULL, CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), @@ -71,9 +76,7 @@ CREATE TABLE "Cars" ( ); ``` -That mechanism does not make sense for JSON:API, because patching a relationship would result in also -changing the identity of a resource. Naming the foreign key explicitly fixes the problem by forcing to -create a foreign key column. +To fix this, name the foreign key explicitly: ```c# protected override void OnModelCreating(ModelBuilder builder) @@ -81,17 +84,18 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(car => car.Engine) .WithOne(engine => engine.Car) - .HasForeignKey("EngineId") // Explicit foreign key name added - .IsRequired(); + .HasForeignKey("EngineId"); // <-- Explicit foreign key name added } ``` Which generates the correct database objects: + ```sql CREATE TABLE "Engine" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") ); + CREATE TABLE "Cars" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, "EngineId" integer NOT NULL, @@ -99,6 +103,99 @@ CREATE TABLE "Cars" ( CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id") ON DELETE CASCADE ); + +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + +#### Optional one-to-one relationships in Entity Framework Core + +For optional one-to-one relationships, Entity Framework Core uses `DeleteBehavior.ClientSetNull` by default, instead of `DeleteBehavior.SetNull`. +This means that Entity Framework Core tries to handle the cascading effects (by sending multiple SQL statements), instead of leaving it up to the database. +Of course that's only going to work when all the related resources are loaded in the change tracker upfront, which is expensive because it requires fetching more data than necessary. + +The reason for this odd default is poor support in SQL Server, as explained [here](https://stackoverflow.com/questions/54326165/ef-core-why-clientsetnull-is-default-ondelete-behavior-for-optional-relations) and [here](https://learn.microsoft.com/en-us/ef/core/saving/cascade-delete#database-cascade-limitations). + +**Our [testing](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1205) shows that these limitations don't exist when using PostgreSQL. +Therefore the general advice is to map the delete behavior of optional one-to-one relationships explicitly with `.OnDelete(DeleteBehavior.SetNull)`. This is simpler and more efficient.** + +The next example defines that each car optionally has an engine, while an engine is optionally linked to a car. + +```c# +#nullable enable + +public sealed class Car : Identifiable +{ + [HasOne] + public Engine? Engine { get; set; } +} + +public sealed class Engine : Identifiable +{ + [HasOne] + public Car? Car { get; set; } +} + +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId"); + } +} +``` + +Which results in Entity Framework Core generating the next database objects: + +```sql +CREATE TABLE "Engines" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engines" PRIMARY KEY ("Id") +); + +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") +); + +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + +To fix this, set the delete behavior explicitly: + +``` +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.SetNull); // <-- Explicit delete behavior set + } +} +``` + +Which generates the correct database objects: + +```sql +CREATE TABLE "Engines" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engines" PRIMARY KEY ("Id") +); + +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") ON DELETE SET NULL +); + CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); ``` diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs index faa496aef9..1eade6b66b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs @@ -10,7 +10,7 @@ public sealed class InjectionDbContext : DbContext { public ISystemClock SystemClock { get; } - public DbSet PostOffice => Set(); + public DbSet PostOffices => Set(); public DbSet GiftCertificates => Set(); public InjectionDbContext(DbContextOptions options, ISystemClock systemClock) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 68e5c1a77a..19b38cf071 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -72,7 +72,7 @@ public async Task Can_filter_resources_by_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.PostOffice.AddRange(postOffices); + dbContext.PostOffices.AddRange(postOffices); await dbContext.SaveChangesAsync(); }); @@ -133,7 +133,7 @@ public async Task Can_create_resource_with_ToOne_relationship_and_include() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.PostOffice.Add(existingOffice); + dbContext.PostOffices.Add(existingOffice); await dbContext.SaveChangesAsync(); }); @@ -216,7 +216,7 @@ public async Task Can_update_resource_with_ToMany_relationship() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.PostOffice.Add(existingOffice); + dbContext.PostOffices.Add(existingOffice); await dbContext.SaveChangesAsync(); }); @@ -259,7 +259,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - PostOffice officeInDatabase = await dbContext.PostOffice.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id); + PostOffice officeInDatabase = await dbContext.PostOffices.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id); officeInDatabase.Address.Should().Be(newAddress); @@ -276,7 +276,7 @@ public async Task Can_delete_resource() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.PostOffice.Add(existingOffice); + dbContext.PostOffices.Add(existingOffice); await dbContext.SaveChangesAsync(); }); @@ -292,7 +292,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - PostOffice? officeInDatabase = await dbContext.PostOffice.FirstWithIdOrDefaultAsync(existingOffice.Id); + PostOffice? officeInDatabase = await dbContext.PostOffices.FirstWithIdOrDefaultAsync(existingOffice.Id); officeInDatabase.Should().BeNull(); }); @@ -359,7 +359,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - PostOffice officeInDatabase = await dbContext.PostOffice.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id); + PostOffice officeInDatabase = await dbContext.PostOffices.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id); officeInDatabase.GiftCertificates.ShouldHaveCount(2); });