Skip to content

Commit 65f9b49

Browse files
maureiBart Koelman
and
Bart Koelman
authored
Fix: crash when deserializing post with relationship to abstract base class (#833)
Co-authored-by: Bart Koelman <[email protected]>
1 parent 10dd16a commit 65f9b49

26 files changed

+662
-71
lines changed

src/Examples/JsonApiDotNetCoreExample/Controllers/KebabCasedModelsController.cs

Lines changed: 0 additions & 18 deletions
This file was deleted.

src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ private IReadOnlyCollection<RelationshipAttribute> GetRelationships(Type resourc
187187
var throughProperties = throughType.GetProperties();
188188

189189
// ArticleTag.Article
190-
hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType == resourceType)
190+
hasManyThroughAttribute.LeftProperty = throughProperties.SingleOrDefault(x => x.PropertyType.IsAssignableFrom(resourceType))
191191
?? throw new InvalidConfigurationException($"{throughType} does not contain a navigation property to type {resourceType}");
192192

193193
// ArticleTag.ArticleId

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,15 +264,20 @@ private IEnumerable GetTrackedManyRelationshipValue(IEnumerable<IIdentifiable> r
264264
{
265265
if (relationshipValues == null) return null;
266266
bool newWasAlreadyAttached = false;
267+
267268
var trackedPointerCollection = TypeHelper.CopyToTypedCollection(relationshipValues.Select(pointer =>
268269
{
269-
// convert each element in the value list to relationshipAttr.DependentType.
270270
var tracked = AttachOrGetTracked(pointer);
271271
if (tracked != null) newWasAlreadyAttached = true;
272-
return Convert.ChangeType(tracked ?? pointer, relationshipAttr.RightType);
272+
273+
var trackedPointer = tracked ?? pointer;
274+
275+
// We should recalculate the target type for every iteration because types may vary. This is possible with resource inheritance.
276+
return Convert.ChangeType(trackedPointer, trackedPointer.GetType());
273277
}), relationshipAttr.Property.PropertyType);
274278

275279
if (newWasAlreadyAttached) wasAlreadyAttached = true;
280+
276281
return trackedPointerCollection;
277282
}
278283

src/JsonApiDotNetCore/Resources/ResourceFactory.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ private static ConstructorInfo GetLongestConstructor(Type type)
116116
{
117117
ConstructorInfo[] constructors = type.GetConstructors().Where(c => !c.IsStatic).ToArray();
118118

119+
if (constructors.Length == 0)
120+
{
121+
throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'.");
122+
}
123+
119124
ConstructorInfo bestMatch = constructors[0];
120125
int maxParameterLength = constructors[0].GetParameters().Length;
121126

src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio
106106
var resourceProperties = resource.GetType().GetProperties();
107107
foreach (var attr in relationshipAttributes)
108108
{
109-
if (!relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData) || !relationshipData.IsPopulated)
109+
var relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData);
110+
if (!relationshipIsProvided || !relationshipData.IsPopulated)
110111
continue;
111112

112113
if (attr is HasOneAttribute hasOneAttribute)
@@ -168,15 +169,19 @@ private void SetHasOneRelationship(IIdentifiable resource,
168169
var rio = (ResourceIdentifierObject)relationshipData.Data;
169170
var relatedId = rio?.Id;
170171

172+
var relationshipType = relationshipData.SingleData == null
173+
? attr.RightType
174+
: ResourceContextProvider.GetResourceContext(relationshipData.SingleData.Type).ResourceType;
175+
171176
// this does not make sense in the following case: if we're setting the dependent of a one-to-one relationship, IdentifiablePropertyName should be null.
172177
var foreignKeyProperty = resourceProperties.FirstOrDefault(p => p.Name == attr.IdentifiablePropertyName);
173178

174179
if (foreignKeyProperty != null)
175180
// there is a FK from the current resource pointing to the related object,
176181
// i.e. we're populating the relationship from the dependent side.
177-
SetForeignKey(resource, foreignKeyProperty, attr, relatedId);
182+
SetForeignKey(resource, foreignKeyProperty, attr, relatedId, relationshipType);
178183

179-
SetNavigation(resource, attr, relatedId);
184+
SetNavigation(resource, attr, relatedId, relationshipType);
180185

181186
// depending on if this base parser is used client-side or server-side,
182187
// different additional processing per field needs to be executed.
@@ -185,9 +190,10 @@ private void SetHasOneRelationship(IIdentifiable resource,
185190

186191
/// <summary>
187192
/// Sets the dependent side of a HasOne relationship, which means that a
188-
/// foreign key also will to be populated.
193+
/// foreign key also will be populated.
189194
/// </summary>
190-
private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id)
195+
private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasOneAttribute attr, string id,
196+
Type relationshipType)
191197
{
192198
bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKey.PropertyType) != null
193199
|| foreignKey.PropertyType == typeof(string);
@@ -198,23 +204,24 @@ private void SetForeignKey(IIdentifiable resource, PropertyInfo foreignKey, HasO
198204
throw new FormatException($"Cannot set required relationship identifier '{attr.IdentifiablePropertyName}' to null because it is a non-nullable type.");
199205
}
200206

201-
var typedId = TypeHelper.ConvertStringIdToTypedId(attr.Property.PropertyType, id, ResourceFactory);
207+
var typedId = TypeHelper.ConvertStringIdToTypedId(relationshipType, id, ResourceFactory);
202208
foreignKey.SetValue(resource, typedId);
203209
}
204210

205211
/// <summary>
206212
/// Sets the principal side of a HasOne relationship, which means no
207213
/// foreign key is involved.
208214
/// </summary>
209-
private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId)
215+
private void SetNavigation(IIdentifiable resource, HasOneAttribute attr, string relatedId,
216+
Type relationshipType)
210217
{
211218
if (relatedId == null)
212219
{
213220
attr.SetValue(resource, null, ResourceFactory);
214221
}
215222
else
216223
{
217-
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(attr.RightType);
224+
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType);
218225
relatedInstance.StringId = relatedId;
219226
attr.SetValue(resource, relatedInstance, ResourceFactory);
220227
}
@@ -232,8 +239,10 @@ private void SetHasManyRelationship(
232239
{ // if the relationship is set to null, no need to set the navigation property to null: this is the default value.
233240
var relatedResources = relationshipData.ManyData.Select(rio =>
234241
{
235-
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(attr.RightType);
242+
var relationshipType = ResourceContextProvider.GetResourceContext(rio.Type).ResourceType;
243+
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipType);
236244
relatedInstance.StringId = rio.Id;
245+
237246
return relatedInstance;
238247
});
239248

src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA
7272
{
7373
// add attributes and relationships of a parsed HasOne relationship
7474
var rio = data.SingleData;
75-
hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(hasOneAttr, rio), ResourceFactory);
75+
hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio), ResourceFactory);
7676
}
7777
else if (field is HasManyAttribute hasManyAttr)
7878
{ // add attributes and relationships of a parsed HasMany relationship
79-
var items = data.ManyData.Select(rio => ParseIncludedRelationship(hasManyAttr, rio));
79+
var items = data.ManyData.Select(rio => ParseIncludedRelationship(rio));
8080
var values = TypeHelper.CopyToTypedCollection(items, hasManyAttr.Property.PropertyType);
8181
hasManyAttr.SetValue(resource, values, ResourceFactory);
8282
}
@@ -85,21 +85,26 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA
8585
/// <summary>
8686
/// Searches for and parses the included relationship.
8787
/// </summary>
88-
private IIdentifiable ParseIncludedRelationship(RelationshipAttribute relationshipAttr, ResourceIdentifierObject relatedResourceIdentifier)
88+
private IIdentifiable ParseIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier)
8989
{
90-
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relationshipAttr.RightType);
90+
var relatedResourceContext = ResourceContextProvider.GetResourceContext(relatedResourceIdentifier.Type);
91+
92+
if (relatedResourceContext == null)
93+
{
94+
throw new InvalidOperationException($"Included type '{relatedResourceIdentifier.Type}' is not a registered json:api resource.");
95+
}
96+
97+
var relatedInstance = (IIdentifiable)ResourceFactory.CreateInstance(relatedResourceContext.ResourceType);
9198
relatedInstance.StringId = relatedResourceIdentifier.Id;
9299

93100
var includedResource = GetLinkedResource(relatedResourceIdentifier);
94-
if (includedResource == null)
95-
return relatedInstance;
96101

97-
var resourceContext = ResourceContextProvider.GetResourceContext(relatedResourceIdentifier.Type);
98-
if (resourceContext == null)
99-
throw new InvalidOperationException($"Included type '{relationshipAttr.RightType}' is not a registered json:api resource.");
100-
101-
SetAttributes(relatedInstance, includedResource.Attributes, resourceContext.Attributes);
102-
SetRelationships(relatedInstance, includedResource.Relationships, resourceContext.Relationships);
102+
if (includedResource != null)
103+
{
104+
SetAttributes(relatedInstance, includedResource.Attributes, relatedResourceContext.Attributes);
105+
SetRelationships(relatedInstance, includedResource.Relationships, relatedResourceContext.Relationships);
106+
}
107+
103108
return relatedInstance;
104109
}
105110

src/JsonApiDotNetCore/Serialization/JsonApiReader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ private void ValidateIncomingResourceType(InputFormatterContext context, object
8989
var bodyResourceTypes = GetBodyResourceTypes(model);
9090
foreach (var bodyResourceType in bodyResourceTypes)
9191
{
92-
if (bodyResourceType != endpointResourceType)
92+
if (!endpointResourceType.IsAssignableFrom(bodyResourceType))
9393
{
9494
var resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType);
9595
var resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType);

test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55
using Bogus;
66
using FluentAssertions;
77
using JsonApiDotNetCore.Configuration;
8+
using JsonApiDotNetCore.Controllers;
89
using JsonApiDotNetCore.Resources;
910
using JsonApiDotNetCore.Serialization.Client.Internal;
1011
using JsonApiDotNetCore.Serialization.Objects;
12+
using JsonApiDotNetCore.Services;
1113
using JsonApiDotNetCoreExample;
1214
using JsonApiDotNetCoreExample.Data;
1315
using JsonApiDotNetCoreExample.Models;
14-
using Microsoft.AspNetCore.Mvc.ApplicationParts;
1516
using Microsoft.EntityFrameworkCore;
1617
using Microsoft.Extensions.Configuration;
1718
using Microsoft.Extensions.DependencyInjection;
19+
using Microsoft.Extensions.Logging;
1820
using Newtonsoft.Json.Linq;
1921
using Newtonsoft.Json.Serialization;
2022
using Xunit;
@@ -32,12 +34,6 @@ public KebabCaseFormatterTests(IntegrationTestContext<KebabCaseStartup, AppDbCon
3234

3335
_faker = new Faker<KebabCasedModel>()
3436
.RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence());
35-
36-
testContext.ConfigureServicesAfterStartup(services =>
37-
{
38-
var part = new AssemblyPart(typeof(EmptyStartup).Assembly);
39-
services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part));
40-
});
4137
}
4238

4339
[Fact]
@@ -192,4 +188,14 @@ protected override void ConfigureJsonApiOptions(JsonApiOptions options)
192188
((DefaultContractResolver)options.SerializerSettings.ContractResolver).NamingStrategy = new KebabCaseNamingStrategy();
193189
}
194190
}
191+
192+
public sealed class KebabCasedModelsController : JsonApiController<KebabCasedModel>
193+
{
194+
public KebabCasedModelsController(
195+
IJsonApiOptions options,
196+
ILoggerFactory loggerFactory,
197+
IResourceService<KebabCasedModel> resourceService)
198+
: base(options, loggerFactory, resourceService)
199+
{ }
200+
}
195201
}

test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,52 @@ public static class AppDbContextExtensions
99
{
1010
public static async Task ClearTableAsync<TEntity>(this DbContext dbContext) where TEntity : class
1111
{
12-
var entityType = dbContext.Model.FindEntityType(typeof(TEntity));
13-
if (entityType == null)
12+
await ClearTablesAsync(dbContext,typeof(TEntity));
13+
}
14+
15+
public static async Task ClearTablesAsync<TEntity1, TEntity2>(this DbContext dbContext) where TEntity1 : class
16+
where TEntity2 : class
17+
{
18+
await ClearTablesAsync(dbContext,typeof(TEntity1), typeof(TEntity2));
19+
}
20+
21+
public static async Task ClearTablesAsync<TEntity1, TEntity2, TEntity3>(this DbContext dbContext) where TEntity1 : class
22+
where TEntity2 : class
23+
where TEntity3 : class
24+
{
25+
await ClearTablesAsync(dbContext,typeof(TEntity1), typeof(TEntity2), typeof(TEntity3));
26+
}
27+
28+
public static async Task ClearTablesAsync<TEntity1, TEntity2, TEntity3, TEntity4>(this DbContext dbContext) where TEntity1 : class
29+
where TEntity2 : class
30+
where TEntity3 : class
31+
where TEntity4 : class
32+
{
33+
await ClearTablesAsync(dbContext, typeof(TEntity1), typeof(TEntity2), typeof(TEntity3), typeof(TEntity4));
34+
}
35+
36+
private static async Task ClearTablesAsync(this DbContext dbContext, params Type[] models)
37+
{
38+
foreach (var model in models)
1439
{
15-
throw new InvalidOperationException($"Table for '{typeof(TEntity).Name}' not found.");
16-
}
40+
var entityType = dbContext.Model.FindEntityType(model);
41+
if (entityType == null)
42+
{
43+
throw new InvalidOperationException($"Table for '{model.Name}' not found.");
44+
}
1745

18-
string tableName = entityType.GetTableName();
46+
string tableName = entityType.GetTableName();
1947

20-
// PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated.
21-
// In that case, we recursively delete all related data, which is slow.
22-
try
23-
{
24-
await dbContext.Database.ExecuteSqlRawAsync("delete from \"" + tableName + "\"");
25-
}
26-
catch (PostgresException)
27-
{
28-
await dbContext.Database.ExecuteSqlRawAsync("truncate table \"" + tableName + "\" cascade");
48+
// PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated.
49+
// In that case, we recursively delete all related data, which is slow.
50+
try
51+
{
52+
await dbContext.Database.ExecuteSqlRawAsync("delete from \"" + tableName + "\"");
53+
}
54+
catch (PostgresException)
55+
{
56+
await dbContext.Database.ExecuteSqlRawAsync("truncate table \"" + tableName + "\" cascade");
57+
}
2958
}
3059
}
3160

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance.Models;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance
5+
{
6+
public sealed class InheritanceDbContext : DbContext
7+
{
8+
public InheritanceDbContext(DbContextOptions<InheritanceDbContext> options) : base(options) { }
9+
10+
public DbSet<Human> Humans { get; set; }
11+
12+
public DbSet<Man> Men { get; set; }
13+
14+
public DbSet<CompanyHealthInsurance> CompanyHealthInsurances { get; set; }
15+
16+
public DbSet<ContentItem> ContentItems { get; set; }
17+
18+
public DbSet<HumanFavoriteContentItem> HumanFavoriteContentItems { get; set; }
19+
20+
protected override void OnModelCreating(ModelBuilder modelBuilder)
21+
{
22+
modelBuilder.Entity<Human>()
23+
.HasDiscriminator<int>("Type")
24+
.HasValue<Man>(1)
25+
.HasValue<Woman>(2);
26+
27+
modelBuilder.Entity<HealthInsurance>()
28+
.HasDiscriminator<int>("Type")
29+
.HasValue<CompanyHealthInsurance>(1)
30+
.HasValue<FamilyHealthInsurance>(2);
31+
32+
modelBuilder.Entity<ContentItem>()
33+
.HasDiscriminator<int>("Type")
34+
.HasValue<Video>(1)
35+
.HasValue<Book>(2);
36+
37+
modelBuilder.Entity<HumanFavoriteContentItem>()
38+
.HasKey(hfci => new { ContentPersonId = hfci.ContentItemId, PersonId = hfci.HumanId });
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)