Skip to content

Commit e42a7a4

Browse files
Bart Koelmanmaurei
Bart Koelman
andauthored
Fix exception propagation (#1021)
* The exception thrown and wrapped on SaveChanges failure was superseeded while disposing PlaceholderResourceCollector that threw too, resulting in a 500 error response. And it produced noisy logs in cibuild, coming from ExceptionHandler. This commit swallows the second exception so the original one can propagate through the pipeline as intended. * Specified a FK in scenario of a required one-to-one relationship. Note that not specifying it results in EF Core configuring this relationship using an identifying FK on the dependent side (Shipment), which is a type of modeling that does not make sense in the scope of JSON:API. * Updated tests to cover for zero-or-one-to-one cases * add comment explaining why this change was needed * Clarified comment * Verified existing one-to-one relationships in our codebase and updated to use an explicit foreign key accordingly. It turns out that not in all cases this makes a difference. I wonder under which circumstances EF Core chooses to use an identifying foreign key and when it does not. * Fixed missing body assertions Co-authored-by: maurei <[email protected]>
1 parent d99355e commit e42a7a4

File tree

10 files changed

+50
-31
lines changed

10 files changed

+50
-31
lines changed

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke
528528
{
529529
await _dbContext.SaveChangesAsync(cancellationToken);
530530
}
531-
catch (DbUpdateException exception)
531+
catch (Exception exception) when (exception is DbUpdateException || exception is InvalidOperationException)
532532
{
533533
if (_dbContext.Database.CurrentTransaction != null)
534534
{

src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs

+9-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ private void Detach(IEnumerable<object> resources)
7070
{
7171
foreach (object resource in resources)
7272
{
73-
_dbContext.Entry(resource).State = EntityState.Detached;
73+
try
74+
{
75+
_dbContext.Entry(resource).State = EntityState.Detached;
76+
}
77+
catch (InvalidOperationException)
78+
{
79+
// If SaveChanges() threw due to a foreign key constraint violation, its exception is rethrown here.
80+
// We swallow this exception, to allow the originating error to propagate.
81+
}
7482
}
7583
}
7684
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ protected override void OnModelCreating(ModelBuilder builder)
3333
builder.Entity<MusicTrack>()
3434
.HasOne(musicTrack => musicTrack.Lyric)
3535
.WithOne(lyric => lyric.Track)
36-
.HasForeignKey<MusicTrack>();
36+
.HasForeignKey<MusicTrack>("LyricId");
3737
}
3838
}
3939
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ protected override void OnModelCreating(ModelBuilder builder)
2222
builder.Entity<Building>()
2323
.HasOne(building => building.PrimaryDoor)
2424
.WithOne()
25-
.HasForeignKey<Building>("PrimaryDoorKey")
25+
.HasForeignKey<Building>("PrimaryDoorId")
2626
.IsRequired();
2727

2828
builder.Entity<Building>()
2929
.HasOne(building => building.SecondaryDoor)
3030
.WithOne()
31-
.HasForeignKey<Building>("SecondaryDoorKey")
31+
.HasForeignKey<Building>("SecondaryDoorId")
3232
.IsRequired(false);
3333
}
3434
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ protected override void OnModelCreating(ModelBuilder builder)
2222
builder.Entity<Photo>()
2323
.HasOne(photo => photo.Location)
2424
.WithOne(location => location.Photo)
25-
.HasForeignKey<Photo>("PhotoLocationKey");
25+
.HasForeignKey<Photo>("LocationId");
2626
}
2727
}
2828
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ protected override void OnModelCreating(ModelBuilder builder)
3333
builder.Entity<WorkItemGroup>()
3434
.HasOne(workItemGroup => workItemGroup.Color)
3535
.WithOne(color => color.Group)
36-
.HasForeignKey<RgbColor>();
36+
.HasForeignKey<RgbColor>("GroupId");
3737

3838
builder.Entity<WorkItemTag>()
3939
.HasKey(workItemTag => new

test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorDbContext.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ protected override void OnModelCreating(ModelBuilder builder)
2424
.WithOne(order => order.Customer)
2525
.IsRequired();
2626

27+
// By default, EF 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.
29+
// That mechanism does not make sense for JSON:API, because patching a relationship would result in
30+
// also changing the identity of a resource. Naming the key explicitly forces to create a foreign key column.
2731
builder.Entity<Order>()
2832
.HasOne(order => order.Shipment)
2933
.WithOne(shipment => shipment.Order)
30-
.HasForeignKey<Shipment>()
34+
.HasForeignKey<Shipment>("OrderId")
3135
.IsRequired();
3236
}
3337
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs

+27-20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using JsonApiDotNetCore.Configuration;
66
using JsonApiDotNetCore.Serialization.Objects;
77
using JsonApiDotNetCoreExampleTests.Startups;
8+
using Microsoft.EntityFrameworkCore;
89
using Microsoft.Extensions.DependencyInjection;
910
using TestBuildingBlocks;
1011
using Xunit;
@@ -113,12 +114,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
113114
string route = $"/customers/{existingOrder.Customer.Id}";
114115

115116
// Act
116-
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route);
117+
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync<string>(route);
117118

118119
// Assert
119120
httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent);
120121

121-
responseDocument.Should().BeNull();
122+
responseDocument.Should().BeEmpty();
122123

123124
await _testContext.RunOnDatabaseAsync(async dbContext =>
124125
{
@@ -147,12 +148,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
147148
string route = $"/orders/{existingOrder.Id}";
148149

149150
// Act
150-
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync<Document>(route);
151+
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync<string>(route);
151152

152153
// Assert
153154
httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent);
154155

155-
responseDocument.Should().BeNull();
156+
responseDocument.Should().BeEmpty();
156157

157158
await _testContext.RunOnDatabaseAsync(async dbContext =>
158159
{
@@ -382,7 +383,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
382383
}
383384

384385
[Fact]
385-
public async Task Cannot_reassign_dependent_side_of_OneToOne_relationship_with_identifying_foreign_key_through_primary_endpoint()
386+
public async Task Can_reassign_dependent_side_of_ZeroOrOneToOne_relationship_through_primary_endpoint()
386387
{
387388
// Arrange
388389
Order orderWithShipment = _fakers.Orders.Generate();
@@ -421,21 +422,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
421422
string route = $"/orders/{orderWithoutShipment.Id}";
422423

423424
// Act
424-
(HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync<ErrorDocument>(route, requestBody);
425+
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync<string>(route, requestBody);
425426

426427
// Assert
427-
httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError);
428+
httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent);
428429

429-
responseDocument.Errors.Should().HaveCount(1);
430+
responseDocument.Should().BeEmpty();
430431

431-
Error error = responseDocument.Errors[0];
432-
error.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
433-
error.Title.Should().Be("An unhandled error occurred while processing this request.");
434-
error.Detail.Should().StartWith("The property 'Id' on entity type 'Shipment' is part of a key and so cannot be modified or marked as modified.");
432+
await _testContext.RunOnDatabaseAsync(async dbContext =>
433+
{
434+
Shipment existingShipmentInDatabase =
435+
await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdOrDefaultAsync(orderWithShipment.Shipment.Id);
436+
437+
existingShipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id);
438+
});
435439
}
436440

437441
[Fact]
438-
public async Task Cannot_reassign_dependent_side_of_OneToOne_relationship_with_identifying_foreign_key_through_relationship_endpoint()
442+
public async Task Can_reassign_dependent_side_of_ZeroOrOneToOne_relationship_through_relationship_endpoint()
439443
{
440444
// Arrange
441445
Order orderWithShipment = _fakers.Orders.Generate();
@@ -463,17 +467,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
463467
string route = $"/orders/{orderWithoutShipment.Id}/relationships/shipment";
464468

465469
// Act
466-
(HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync<ErrorDocument>(route, requestBody);
470+
(HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync<string>(route, requestBody);
467471

468472
// Assert
469-
httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError);
473+
httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent);
470474

471-
responseDocument.Errors.Should().HaveCount(1);
475+
responseDocument.Should().BeEmpty();
472476

473-
Error error = responseDocument.Errors[0];
474-
error.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
475-
error.Title.Should().Be("An unhandled error occurred while processing this request.");
476-
error.Detail.Should().StartWith("The property 'Id' on entity type 'Shipment' is part of a key and so cannot be modified or marked as modified.");
477+
await _testContext.RunOnDatabaseAsync(async dbContext =>
478+
{
479+
Shipment existingShipmentInDatabase =
480+
await dbContext.Shipments.Include(shipment => shipment.Order).FirstWithIdOrDefaultAsync(orderWithShipment.Shipment.Id);
481+
482+
existingShipmentInDatabase.Order.Id.Should().Be(orderWithoutShipment.Id);
483+
});
477484
}
478485
}
479486
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HooksDbContext.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ protected override void OnModelCreating(ModelBuilder builder)
3737
builder.Entity<Passport>()
3838
.HasOne(passport => passport.Person)
3939
.WithOne(person => person.Passport)
40-
.HasForeignKey<Person>("PassportKey")
40+
.HasForeignKey<Person>("PassportId")
4141
.OnDelete(DeleteBehavior.SetNull);
4242
}
4343
}

test/UnitTests/ResourceHooks/HooksDbContext.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ protected override void OnModelCreating(ModelBuilder builder)
5454
builder.Entity<Passport>()
5555
.HasOne(passport => passport.Person)
5656
.WithOne(person => person.Passport)
57-
.HasForeignKey<Person>("PassportKey")
57+
.HasForeignKey<Person>("PassportId")
5858
.OnDelete(DeleteBehavior.SetNull);
5959

6060
builder.Entity<TodoItem>()
6161
.HasOne(todoItem => todoItem.OneToOnePerson)
6262
.WithOne(person => person.OneToOneTodoItem)
63-
.HasForeignKey<TodoItem>("OneToOnePersonKey");
63+
.HasForeignKey<TodoItem>("OneToOnePersonId");
6464
}
6565
}
6666
}

0 commit comments

Comments
 (0)