Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Lines changed: 108 additions & 11 deletions
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-oneand 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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 7 additions & 7 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /