Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update EF Core docs for one-to-one relationships #1207

Merged
merged 2 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 108 additions & 11 deletions docs/usage/resources/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,10 +22,14 @@ public class TodoItem : Identifiable<int>

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.

Expand All @@ -51,18 +55,19 @@ public sealed class AppDbContext : DbContext
builder.Entity<Car>()
.HasOne(car => car.Engine)
.WithOne(engine => engine.Car)
.HasForeignKey<Car>()
.IsRequired();
.HasForeignKey<Car>();
}
}
```

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"),
Expand All @@ -71,34 +76,126 @@ 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)
{
builder.Entity<Car>()
.HasOne(car => car.Engine)
.WithOne(engine => engine.Car)
.HasForeignKey<Car>("EngineId") // Explicit foreign key name added
.IsRequired();
.HasForeignKey<Car>("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,
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
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<int>
{
[HasOne]
public Engine? Engine { get; set; }
}

public sealed class Engine : Identifiable<int>
{
[HasOne]
public Car? Car { get; set; }
}

public sealed class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Car>()
.HasOne(car => car.Engine)
.WithOne(engine => engine.Car)
.HasForeignKey<Car>("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<Car>()
.HasOne(car => car.Engine)
.WithOne(engine => engine.Car)
.HasForeignKey<Car>("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");
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public sealed class InjectionDbContext : DbContext
{
public ISystemClock SystemClock { get; }

public DbSet<PostOffice> PostOffice => Set<PostOffice>();
public DbSet<PostOffice> PostOffices => Set<PostOffice>();
public DbSet<GiftCertificate> GiftCertificates => Set<GiftCertificate>();

public InjectionDbContext(DbContextOptions<InjectionDbContext> options, ISystemClock systemClock)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public async Task Can_filter_resources_by_ID()
await _testContext.RunOnDatabaseAsync(async dbContext =>
{
await dbContext.ClearTableAsync<PostOffice>();
dbContext.PostOffice.AddRange(postOffices);
dbContext.PostOffices.AddRange(postOffices);
await dbContext.SaveChangesAsync();
});

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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);

Expand All @@ -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();
});

Expand All @@ -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();
});
Expand Down Expand Up @@ -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);
});
Expand Down