Skip to content

Commit d6286c3

Browse files
authored
Merge pull request #1207 from json-api-dotnet/docs-one-to-one-relationships
Update EF Core docs for one-to-one relationships
2 parents 6acf0cd + bbec95b commit d6286c3

File tree

3 files changed

+116
-19
lines changed

3 files changed

+116
-19
lines changed

docs/usage/resources/relationships.md

+108-11
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
A relationship is a named link between two resource types, including a direction.
44
They are similar to [navigation properties in Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/modeling/relationships).
55

6-
Relationships come in three flavors: to-one, to-many and many-to-many.
6+
Relationships come in two flavors: to-one and to-many.
77
The left side of a relationship is where the relationship is declared, the right side is the resource type it points to.
88

99
## HasOne
@@ -22,10 +22,14 @@ public class TodoItem : Identifiable<int>
2222

2323
The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons").
2424

25-
### Required one-to-one relationships in Entity Framework Core
25+
### One-to-one relationships in Entity Framework Core
2626

27-
By default, Entity Framework Core generates an identifying foreign key for a required 1-to-1 relationship.
28-
This means no foreign key column is generated, instead the primary keys point to each other directly.
27+
By default, Entity Framework Core tries to generate an *identifying foreign key* for a one-to-one relationship whenever possible.
28+
In that case, no foreign key column is generated. Instead the primary keys point to each other directly.
29+
30+
**That mechanism does not make sense for JSON:API, because patching a relationship would result in also
31+
changing the identity of a resource. Naming the foreign key explicitly fixes the problem, which enforces
32+
to create a foreign key column.**
2933

3034
The next example defines that each car requires an engine, while an engine is optionally linked to a car.
3135

@@ -51,18 +55,19 @@ public sealed class AppDbContext : DbContext
5155
builder.Entity<Car>()
5256
.HasOne(car => car.Engine)
5357
.WithOne(engine => engine.Car)
54-
.HasForeignKey<Car>()
55-
.IsRequired();
58+
.HasForeignKey<Car>();
5659
}
5760
}
5861
```
5962

6063
Which results in Entity Framework Core generating the next database objects:
64+
6165
```sql
6266
CREATE TABLE "Engine" (
6367
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
6468
CONSTRAINT "PK_Engine" PRIMARY KEY ("Id")
6569
);
70+
6671
CREATE TABLE "Cars" (
6772
"Id" integer NOT NULL,
6873
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
@@ -71,34 +76,126 @@ CREATE TABLE "Cars" (
7176
);
7277
```
7378

74-
That mechanism does not make sense for JSON:API, because patching a relationship would result in also
75-
changing the identity of a resource. Naming the foreign key explicitly fixes the problem by forcing to
76-
create a foreign key column.
79+
To fix this, name the foreign key explicitly:
7780

7881
```c#
7982
protected override void OnModelCreating(ModelBuilder builder)
8083
{
8184
builder.Entity<Car>()
8285
.HasOne(car => car.Engine)
8386
.WithOne(engine => engine.Car)
84-
.HasForeignKey<Car>("EngineId") // Explicit foreign key name added
85-
.IsRequired();
87+
.HasForeignKey<Car>("EngineId"); // <-- Explicit foreign key name added
8688
}
8789
```
8890

8991
Which generates the correct database objects:
92+
9093
```sql
9194
CREATE TABLE "Engine" (
9295
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
9396
CONSTRAINT "PK_Engine" PRIMARY KEY ("Id")
9497
);
98+
9599
CREATE TABLE "Cars" (
96100
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
97101
"EngineId" integer NOT NULL,
98102
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
99103
CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id")
100104
ON DELETE CASCADE
101105
);
106+
107+
CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");
108+
```
109+
110+
#### Optional one-to-one relationships in Entity Framework Core
111+
112+
For optional one-to-one relationships, Entity Framework Core uses `DeleteBehavior.ClientSetNull` by default, instead of `DeleteBehavior.SetNull`.
113+
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.
114+
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.
115+
116+
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).
117+
118+
**Our [testing](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1205) shows that these limitations don't exist when using PostgreSQL.
119+
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.**
120+
121+
The next example defines that each car optionally has an engine, while an engine is optionally linked to a car.
122+
123+
```c#
124+
#nullable enable
125+
126+
public sealed class Car : Identifiable<int>
127+
{
128+
[HasOne]
129+
public Engine? Engine { get; set; }
130+
}
131+
132+
public sealed class Engine : Identifiable<int>
133+
{
134+
[HasOne]
135+
public Car? Car { get; set; }
136+
}
137+
138+
public sealed class AppDbContext : DbContext
139+
{
140+
protected override void OnModelCreating(ModelBuilder builder)
141+
{
142+
builder.Entity<Car>()
143+
.HasOne(car => car.Engine)
144+
.WithOne(engine => engine.Car)
145+
.HasForeignKey<Car>("EngineId");
146+
}
147+
}
148+
```
149+
150+
Which results in Entity Framework Core generating the next database objects:
151+
152+
```sql
153+
CREATE TABLE "Engines" (
154+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
155+
CONSTRAINT "PK_Engines" PRIMARY KEY ("Id")
156+
);
157+
158+
CREATE TABLE "Cars" (
159+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
160+
"EngineId" integer NULL,
161+
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
162+
CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id")
163+
);
164+
165+
CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");
166+
```
167+
168+
To fix this, set the delete behavior explicitly:
169+
170+
```
171+
public sealed class AppDbContext : DbContext
172+
{
173+
protected override void OnModelCreating(ModelBuilder builder)
174+
{
175+
builder.Entity<Car>()
176+
.HasOne(car => car.Engine)
177+
.WithOne(engine => engine.Car)
178+
.HasForeignKey<Car>("EngineId")
179+
.OnDelete(DeleteBehavior.SetNull); // <-- Explicit delete behavior set
180+
}
181+
}
182+
```
183+
184+
Which generates the correct database objects:
185+
186+
```sql
187+
CREATE TABLE "Engines" (
188+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
189+
CONSTRAINT "PK_Engines" PRIMARY KEY ("Id")
190+
);
191+
192+
CREATE TABLE "Cars" (
193+
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
194+
"EngineId" integer NULL,
195+
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
196+
CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") ON DELETE SET NULL
197+
);
198+
102199
CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");
103200
```
104201

test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public sealed class InjectionDbContext : TestableDbContext
1111
{
1212
public ISystemClock SystemClock { get; }
1313

14-
public DbSet<PostOffice> PostOffice => Set<PostOffice>();
14+
public DbSet<PostOffice> PostOffices => Set<PostOffice>();
1515
public DbSet<GiftCertificate> GiftCertificates => Set<GiftCertificate>();
1616

1717
public InjectionDbContext(DbContextOptions<InjectionDbContext> options, ISystemClock systemClock)

test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs

+7-7
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public async Task Can_filter_resources_by_ID()
7272
await _testContext.RunOnDatabaseAsync(async dbContext =>
7373
{
7474
await dbContext.ClearTableAsync<PostOffice>();
75-
dbContext.PostOffice.AddRange(postOffices);
75+
dbContext.PostOffices.AddRange(postOffices);
7676
await dbContext.SaveChangesAsync();
7777
});
7878

@@ -133,7 +133,7 @@ public async Task Can_create_resource_with_ToOne_relationship_and_include()
133133

134134
await _testContext.RunOnDatabaseAsync(async dbContext =>
135135
{
136-
dbContext.PostOffice.Add(existingOffice);
136+
dbContext.PostOffices.Add(existingOffice);
137137
await dbContext.SaveChangesAsync();
138138
});
139139

@@ -216,7 +216,7 @@ public async Task Can_update_resource_with_ToMany_relationship()
216216

217217
await _testContext.RunOnDatabaseAsync(async dbContext =>
218218
{
219-
dbContext.PostOffice.Add(existingOffice);
219+
dbContext.PostOffices.Add(existingOffice);
220220
await dbContext.SaveChangesAsync();
221221
});
222222

@@ -259,7 +259,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
259259

260260
await _testContext.RunOnDatabaseAsync(async dbContext =>
261261
{
262-
PostOffice officeInDatabase = await dbContext.PostOffice.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id);
262+
PostOffice officeInDatabase = await dbContext.PostOffices.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id);
263263

264264
officeInDatabase.Address.Should().Be(newAddress);
265265

@@ -276,7 +276,7 @@ public async Task Can_delete_resource()
276276

277277
await _testContext.RunOnDatabaseAsync(async dbContext =>
278278
{
279-
dbContext.PostOffice.Add(existingOffice);
279+
dbContext.PostOffices.Add(existingOffice);
280280
await dbContext.SaveChangesAsync();
281281
});
282282

@@ -292,7 +292,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
292292

293293
await _testContext.RunOnDatabaseAsync(async dbContext =>
294294
{
295-
PostOffice? officeInDatabase = await dbContext.PostOffice.FirstWithIdOrDefaultAsync(existingOffice.Id);
295+
PostOffice? officeInDatabase = await dbContext.PostOffices.FirstWithIdOrDefaultAsync(existingOffice.Id);
296296

297297
officeInDatabase.Should().BeNull();
298298
});
@@ -359,7 +359,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
359359

360360
await _testContext.RunOnDatabaseAsync(async dbContext =>
361361
{
362-
PostOffice officeInDatabase = await dbContext.PostOffice.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id);
362+
PostOffice officeInDatabase = await dbContext.PostOffices.Include(postOffice => postOffice.GiftCertificates).FirstWithIdAsync(existingOffice.Id);
363363

364364
officeInDatabase.GiftCertificates.ShouldHaveCount(2);
365365
});

0 commit comments

Comments
 (0)