diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings
index c4f0d3d36d..2a7eb28d9b 100644
--- a/JsonApiDotNetCore.sln.DotSettings
+++ b/JsonApiDotNetCore.sln.DotSettings
@@ -629,6 +629,7 @@ $left$ = $right$;
$collection$.IsNullOrEmpty()
$collection$ == null || !$collection$.Any()
WARNING
+ True
True
True
True
diff --git a/ROADMAP.md b/ROADMAP.md
index 559e0bfe7d..c3baab5443 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,13 +27,13 @@ The need for breaking changes has blocked several efforts in the v4.x release, s
- [x] Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365)
- [x] Support .NET 6 with EF Core 6 [#1109](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1109)
- [x] Extract annotations into separate package [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730)
+- [x] Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844)
Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version.
- Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004)
- OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046)
- Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776)
-- Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844)
- Idempotency
## Feedback
diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs
index 6b716a5401..12f5c2e788 100644
--- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs
+++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs
@@ -127,11 +127,19 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG
RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4));
RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5));
- ImmutableArray chain = ImmutableArray.Create(single2, single3, multi4, multi5);
- IEnumerable chains = new ResourceFieldChainExpression(chain).AsEnumerable();
-
- var converter = new IncludeChainConverter();
- IncludeExpression include = converter.FromRelationshipChains(chains);
+ var include = new IncludeExpression(new HashSet
+ {
+ new(single2, new HashSet
+ {
+ new(single3, new HashSet
+ {
+ new(multi4, new HashSet
+ {
+ new(multi5)
+ }.ToImmutableHashSet())
+ }.ToImmutableHashSet())
+ }.ToImmutableHashSet())
+ }.ToImmutableHashSet());
var cache = new EvaluatedIncludeCache();
cache.Set(include);
diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs
index befa5049e8..100ff60e25 100644
--- a/benchmarks/Serialization/SerializationBenchmarkBase.cs
+++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs
@@ -180,9 +180,9 @@ public Task OnSetToManyRelationshipAsync(TResource leftResource, HasM
return Task.CompletedTask;
}
- public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
- where TResource : class, IIdentifiable
+ where TResource : class, IIdentifiable
{
return Task.CompletedTask;
}
diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md
index dab1fdb6e2..a1c1215ccd 100644
--- a/docs/usage/reading/filtering.md
+++ b/docs/usage/reading/filtering.md
@@ -24,6 +24,7 @@ Expressions are composed using the following functions:
| Ends with text | `endsWith` | `?filter=endsWith(description,'End')` |
| Equals one value from set | `any` | `?filter=any(chapter,'Intro','Summary','Conclusion')` |
| Collection contains items | `has` | `?filter=has(articles)` |
+| Type-check derived type (v5) | `isType` | `?filter=isType(,men)` |
| Negation | `not` | `?filter=not(equals(lastName,null))` |
| Conditional logical OR | `or` | `?filter=or(has(orders),has(invoices))` |
| Conditional logical AND | `and` | `?filter=and(has(orders),has(invoices))` |
@@ -86,6 +87,32 @@ GET /customers?filter=has(orders,not(equals(status,'Paid'))) HTTP/1.1
Which returns only customers that have at least one unpaid order.
+_since v5.0_
+
+Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible.
+
+Only return men:
+```http
+GET /humans?filter=isType(,men) HTTP/1.1
+```
+
+Only return men with beards:
+```http
+GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1
+```
+
+The first parameter of `isType` can be used to perform the type check on a to-one relationship path.
+
+Only return people whose best friend is a man with children:
+```http
+GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1
+```
+
+Only return people who have at least one female married child:
+```http
+GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1
+```
+
# Legacy filters
The next section describes how filtering worked in versions prior to v4.0. They are always applied on the set of resources being requested (no nesting).
diff --git a/docs/usage/resources/inheritance.md b/docs/usage/resources/inheritance.md
new file mode 100644
index 0000000000..47cf85ca67
--- /dev/null
+++ b/docs/usage/resources/inheritance.md
@@ -0,0 +1,409 @@
+# Resource inheritance
+
+_since v5.0_
+
+Resource classes can be part of a type hierarchy. For example:
+
+```c#
+#nullable enable
+
+public abstract class Human : Identifiable
+{
+ [Attr]
+ public string Name { get; set; } = null!;
+
+ [HasOne]
+ public Man? Father { get; set; }
+
+ [HasOne]
+ public Woman? Mother { get; set; }
+
+ [HasMany]
+ public ISet Children { get; set; } = new HashSet();
+
+ [HasOne]
+ public Human? BestFriend { get; set; }
+}
+
+public sealed class Man : Human
+{
+ [Attr]
+ public bool HasBeard { get; set; }
+
+ [HasOne]
+ public Woman? Wife { get; set; }
+}
+
+public sealed class Woman : Human
+{
+ [Attr]
+ public string? MaidenName { get; set; }
+
+ [HasOne]
+ public Man? Husband { get; set; }
+}
+```
+
+## Reading data
+
+You can access them through base or derived endpoints.
+
+```http
+GET /humans HTTP/1.1
+
+{
+ "data": [
+ {
+ "type": "women",
+ "id": "1",
+ "attributes": {
+ "maidenName": "Smith",
+ "name": "Jane Doe"
+ },
+ "relationships": {
+ "husband": {
+ "links": {
+ "self": "/women/1/relationships/husband",
+ "related": "/women/1/husband"
+ }
+ },
+ "father": {
+ "links": {
+ "self": "/women/1/relationships/father",
+ "related": "/women/1/father"
+ }
+ },
+ "mother": {
+ "links": {
+ "self": "/women/1/relationships/mother",
+ "related": "/women/1/mother"
+ }
+ },
+ "children": {
+ "links": {
+ "self": "/women/1/relationships/children",
+ "related": "/women/1/children"
+ }
+ },
+ "bestFriend": {
+ "links": {
+ "self": "/women/1/relationships/bestFriend",
+ "related": "/women/1/bestFriend"
+ }
+ }
+ },
+ "links": {
+ "self": "/women/1"
+ }
+ },
+ {
+ "type": "men",
+ "id": "2",
+ "attributes": {
+ "hasBeard": true,
+ "name": "John Doe"
+ },
+ "relationships": {
+ "wife": {
+ "links": {
+ "self": "/men/2/relationships/wife",
+ "related": "/men/2/wife"
+ }
+ },
+ "father": {
+ "links": {
+ "self": "/men/2/relationships/father",
+ "related": "/men/2/father"
+ }
+ },
+ "mother": {
+ "links": {
+ "self": "/men/2/relationships/mother",
+ "related": "/men/2/mother"
+ }
+ },
+ "children": {
+ "links": {
+ "self": "/men/2/relationships/children",
+ "related": "/men/2/children"
+ }
+ },
+ "bestFriend": {
+ "links": {
+ "self": "/men/2/relationships/bestFriend",
+ "related": "/men/2/bestFriend"
+ }
+ }
+ },
+ "links": {
+ "self": "/men/2"
+ }
+ }
+ ]
+}
+```
+
+### Spare fieldsets
+
+If you only want to retrieve the fields from the base type, you can use [sparse fieldsets](~/usage/reading/sparse-fieldset-selection.md).
+
+```http
+GET /humans?fields[men]=name,children&fields[women]=name,children HTTP/1.1
+```
+
+### Includes
+
+Relationships on derived types can be included without special syntax.
+
+```http
+GET /humans?include=husband,wife,children HTTP/1.1
+```
+
+### Sorting
+
+Just like includes, you can sort on derived attributes and relationships.
+
+```http
+GET /humans?sort=maidenName,wife.name HTTP/1.1
+```
+
+This returns all women sorted by their maiden names, followed by all men sorted by the name of their wife.
+
+To accomplish the same from a [Resource Definition](~/usage/extensibility/resource-definitions.md), upcast to the derived type:
+
+```c#
+public override SortExpression OnApplySort(SortExpression? existingSort)
+{
+ return CreateSortExpressionFromLambda(new PropertySortOrder
+ {
+ (human => ((Woman)human).MaidenName, ListSortDirection.Ascending),
+ (human => ((Man)human).Wife!.Name, ListSortDirection.Ascending)
+ });
+}
+```
+
+### Filtering
+
+Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible.
+
+Only return men:
+```http
+GET /humans?filter=isType(,men) HTTP/1.1
+```
+
+Only return men with beards:
+```http
+GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1
+```
+
+The first parameter of `isType` can be used to perform the type check on a to-one relationship path.
+
+Only return people whose best friend is a man with children:
+```http
+GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1
+```
+
+Only return people who have at least one female married child:
+```http
+GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1
+```
+
+## Writing data
+
+Just like reading data, you can use base or derived endpoints. When using relationships in request bodies, you can use base or derived types as well.
+The only exception is that you cannot use an abstract base type in the request body when creating or updating a resource.
+
+For example, updating an attribute and relationship can be done at an abstract endpoint, but its body requires non-abstract types:
+
+```http
+PATCH /humans/2 HTTP/1.1
+
+{
+ "data": {
+ "type": "men",
+ "id": "2",
+ "attributes": {
+ "hasBeard": false
+ },
+ "relationships": {
+ "wife": {
+ "data": {
+ "type": "women",
+ "id": "1"
+ }
+ }
+ }
+ }
+}
+```
+
+Updating a relationship does allow abstract types. For example:
+
+```http
+PATCH /humans/1/relationships/children HTTP/1.1
+
+{
+ "data": [
+ {
+ "type": "humans",
+ "id": "2"
+ }
+ ]
+}
+```
+
+### Request pipeline
+
+The `TResource` type parameter used in controllers, resource services and resource repositories always matches the used endpoint.
+But when JsonApiDotNetCore sees usage of a type from a type hierarchy, it fetches the stored types and updates `IJsonApiRequest` accordingly.
+As a result, `TResource` can be different from what `IJsonApiRequest.PrimaryResourceType` returns.
+
+For example, on the request:
+```http
+ GET /humans/1 HTTP/1.1
+```
+
+JsonApiDotNetCore runs `IResourceService`, but `IJsonApiRequest.PrimaryResourceType` returns `Woman`
+if human with ID 1 is stored as a woman in the underlying data store.
+
+Even with a simple type hierarchy as used here, lots of possible combinations quickly arise. For example, changing someone's best friend can be done using the following requests:
+- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }`
+- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }`
+- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }`
+- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }`
+- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }`
+- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }`
+- `PATCH /humans/1/relationships/bestFriend { "data": { type: "human" ... } }`
+- `PATCH /humans/1/relationships/bestFriend { "data": { type: "women" ... } }`
+- `PATCH /humans/1/relationships/bestFriend { "data": { type: "men" ... } }`
+- `PATCH /women/1/relationships/bestFriend { "data": { type: "human" ... } }`
+- `PATCH /women/1/relationships/bestFriend { "data": { type: "women" ... } }`
+- `PATCH /women/1/relationships/bestFriend { "data": { type: "men" ... } }`
+- `PATCH /men/2/relationships/bestFriend { "data": { type: "human" ... } }`
+- `PATCH /men/2/relationships/bestFriend { "data": { type: "women" ... } }`
+- `PATCH /men/2/relationships/bestFriend { "data": { type: "men" ... } }`
+
+Because of all the possible combinations, implementing business rules in the pipeline is a no-go.
+Resource definitions provide a better solution, see below.
+
+### Resource definitions
+
+In contrast to the request pipeline, JsonApiDotNetCore always executes the resource definition that matches the *stored* type.
+This enables to implement business logic in a central place, irrespective of which endpoint was used or whether base types were used in relationships.
+
+To delegate logic for base types to their matching resource type, you can build a chain of resource definitions. And because you'll always get the
+actually stored types (for relationships too), you can type-check left-side and right-side types in resources definitions.
+
+```c#
+public sealed class HumanDefinition : JsonApiResourceDefinition
+{
+ public HumanDefinition(IResourceGraph resourceGraph)
+ : base(resourceGraph)
+ {
+ }
+
+ public override Task OnSetToOneRelationshipAsync(Human leftResource,
+ HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId,
+ WriteOperationKind writeOperation, CancellationToken cancellationToken)
+ {
+ if (leftResource is Man &&
+ hasOneRelationship.Property.Name == nameof(Human.BestFriend) &&
+ rightResourceId is Woman)
+ {
+ throw new Exception("Men are not supposed to have a female best friend.");
+ }
+
+ return Task.FromResult(rightResourceId);
+ }
+
+ public override Task OnWritingAsync(Human resource, WriteOperationKind writeOperation,
+ CancellationToken cancellationToken)
+ {
+ if (writeOperation is WriteOperationKind.CreateResource or
+ WriteOperationKind.UpdateResource)
+ {
+ if (resource is Man { HasBeard: true })
+ {
+ throw new Exception("Only shaved men, please.");
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class WomanDefinition : JsonApiResourceDefinition
+{
+ private readonly IResourceDefinition _baseDefinition;
+
+ public WomanDefinition(IResourceGraph resourceGraph,
+ IResourceDefinition baseDefinition)
+ : base(resourceGraph)
+ {
+ _baseDefinition = baseDefinition;
+ }
+
+ public override Task OnSetToOneRelationshipAsync(Woman leftResource,
+ HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId,
+ WriteOperationKind writeOperation, CancellationToken cancellationToken)
+ {
+ if (ResourceType.BaseType!.FindRelationshipByPublicName(
+ hasOneRelationship.PublicName) != null)
+ {
+ // Delegate to resource definition for base type Human.
+ return _baseDefinition.OnSetToOneRelationshipAsync(leftResource,
+ hasOneRelationship, rightResourceId, writeOperation, cancellationToken);
+ }
+
+ // Handle here.
+ if (hasOneRelationship.Property.Name == nameof(Woman.Husband) &&
+ rightResourceId == null)
+ {
+ throw new Exception("We don't accept unmarried women at this time.");
+ }
+
+ return Task.FromResult(rightResourceId);
+ }
+
+ public override async Task OnPrepareWriteAsync(Woman resource,
+ WriteOperationKind writeOperation, CancellationToken cancellationToken)
+ {
+ // Run rules in resource definition for base type Human.
+ await _baseDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken);
+
+ // Run rules for type Woman.
+ if (resource.MaidenName == null)
+ {
+ throw new Exception("Women should have a maiden name.");
+ }
+ }
+}
+
+public sealed class ManDefinition : JsonApiResourceDefinition
+{
+ private readonly IResourceDefinition _baseDefinition;
+
+ public ManDefinition(IResourceGraph resourceGraph,
+ IResourceDefinition baseDefinition)
+ : base(resourceGraph)
+ {
+ _baseDefinition = baseDefinition;
+ }
+
+ public override Task OnSetToOneRelationshipAsync(Man leftResource,
+ HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId,
+ WriteOperationKind writeOperation, CancellationToken cancellationToken)
+ {
+ // No man-specific logic, but we'll still need to delegate.
+ return _baseDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship,
+ rightResourceId, writeOperation, cancellationToken);
+ }
+
+ public override Task OnWritingAsync(Man resource, WriteOperationKind writeOperation,
+ CancellationToken cancellationToken)
+ {
+ // No man-specific logic, but we'll still need to delegate.
+ return _baseDefinition.OnWritingAsync(resource, writeOperation, cancellationToken);
+ }
+}
+```
diff --git a/docs/usage/toc.md b/docs/usage/toc.md
index f6924036ea..c30a2b0f37 100644
--- a/docs/usage/toc.md
+++ b/docs/usage/toc.md
@@ -1,6 +1,7 @@
# [Resources](resources/index.md)
## [Attributes](resources/attributes.md)
## [Relationships](resources/relationships.md)
+## [Inheritance](resources/inheritance.md)
## [Nullability](resources/nullability.md)
# Reading data
diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs
index 482e42cdf7..bda826d131 100644
--- a/src/Examples/JsonApiDotNetCoreExample/Program.cs
+++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Diagnostics;
@@ -6,6 +7,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
+[assembly: ExcludeFromCodeCoverage]
+
WebApplication app = CreateWebApplication(args);
await CreateDatabaseAsync(app.Services);
diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
index ce7ccd1870..c9b2f8064d 100644
--- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
+++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs
@@ -11,6 +11,7 @@ public sealed class ResourceType
{
private readonly Dictionary _fieldsByPublicName = new();
private readonly Dictionary _fieldsByPropertyName = new();
+ private readonly Lazy> _lazyAllConcreteDerivedTypes;
///
/// The publicly exposed resource name.
@@ -28,22 +29,35 @@ public sealed class ResourceType
public Type IdentityClrType { get; }
///
- /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields.
+ /// The base resource type, in case this is a derived type.
+ ///
+ public ResourceType? BaseType { get; internal set; }
+
+ ///
+ /// The resource types that directly derive from this one.
+ ///
+ public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = new HashSet();
+
+ ///
+ /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this
+ /// includes the attributes and relationships from base types.
///
public IReadOnlyCollection Fields { get; }
///
- /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes.
+ /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. When using resource inheritance, this includes the
+ /// attributes from base types.
///
public IReadOnlyCollection Attributes { get; }
///
- /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships.
+ /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. When using resource inheritance, this
+ /// includes the relationships from base types.
///
public IReadOnlyCollection Relationships { get; }
///
- /// Related entities that are not exposed as resource relationships.
+ /// Related entities that are not exposed as resource relationships. When using resource inheritance, this includes the eager-loads from base types.
///
public IReadOnlyCollection EagerLoads { get; }
@@ -100,6 +114,29 @@ public ResourceType(string publicName, Type clrType, Type identityClrType, IRead
_fieldsByPublicName.Add(field.PublicName, field);
_fieldsByPropertyName.Add(field.Property.Name, field);
}
+
+ _lazyAllConcreteDerivedTypes = new Lazy>(ResolveAllConcreteDerivedTypes, LazyThreadSafetyMode.PublicationOnly);
+ }
+
+ private IReadOnlySet ResolveAllConcreteDerivedTypes()
+ {
+ var allConcreteDerivedTypes = new HashSet();
+ AddConcreteDerivedTypes(this, allConcreteDerivedTypes);
+
+ return allConcreteDerivedTypes;
+ }
+
+ private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet allConcreteDerivedTypes)
+ {
+ foreach (ResourceType derivedType in resourceType.DirectlyDerivedTypes)
+ {
+ if (!derivedType.ClrType.IsAbstract)
+ {
+ allConcreteDerivedTypes.Add(derivedType);
+ }
+
+ AddConcreteDerivedTypes(derivedType, allConcreteDerivedTypes);
+ }
}
public AttrAttribute GetAttributeByPublicName(string publicName)
@@ -161,6 +198,111 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName)
: null;
}
+ ///
+ /// Returns all directly and indirectly non-abstract resource types that derive from this resource type.
+ ///
+ public IReadOnlySet GetAllConcreteDerivedTypes()
+ {
+ return _lazyAllConcreteDerivedTypes.Value;
+ }
+
+ ///
+ /// Searches the tree of derived types to find a match for the specified .
+ ///
+ public ResourceType GetTypeOrDerived(Type clrType)
+ {
+ ArgumentGuard.NotNull(clrType, nameof(clrType));
+
+ ResourceType? derivedType = FindTypeOrDerived(this, clrType);
+
+ if (derivedType == null)
+ {
+ throw new InvalidOperationException($"Resource type '{PublicName}' is not a base type of '{clrType}'.");
+ }
+
+ return derivedType;
+ }
+
+ private static ResourceType? FindTypeOrDerived(ResourceType type, Type clrType)
+ {
+ if (type.ClrType == clrType)
+ {
+ return type;
+ }
+
+ foreach (ResourceType derivedType in type.DirectlyDerivedTypes)
+ {
+ ResourceType? matchingType = FindTypeOrDerived(derivedType, clrType);
+
+ if (matchingType != null)
+ {
+ return matchingType;
+ }
+ }
+
+ return null;
+ }
+
+ internal IReadOnlySet GetAttributesInTypeOrDerived(string publicName)
+ {
+ return GetAttributesInTypeOrDerived(this, publicName);
+ }
+
+ private static IReadOnlySet GetAttributesInTypeOrDerived(ResourceType resourceType, string publicName)
+ {
+ AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName);
+
+ if (attribute != null)
+ {
+ return attribute.AsHashSet();
+ }
+
+ // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported.
+ // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords
+ HashSet attributesInDerivedTypes = new();
+
+ foreach (AttrAttribute attributeInDerivedType in resourceType.DirectlyDerivedTypes
+ .Select(derivedType => GetAttributesInTypeOrDerived(derivedType, publicName)).SelectMany(attributesInDerivedType => attributesInDerivedType))
+ {
+ attributesInDerivedTypes.Add(attributeInDerivedType);
+ }
+
+ return attributesInDerivedTypes;
+ }
+
+ internal IReadOnlySet GetRelationshipsInTypeOrDerived(string publicName)
+ {
+ return GetRelationshipsInTypeOrDerived(this, publicName);
+ }
+
+ private static IReadOnlySet GetRelationshipsInTypeOrDerived(ResourceType resourceType, string publicName)
+ {
+ RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName);
+
+ if (relationship != null)
+ {
+ return relationship.AsHashSet();
+ }
+
+ // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported.
+ // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords
+ HashSet relationshipsInDerivedTypes = new();
+
+ foreach (RelationshipAttribute relationshipInDerivedType in resourceType.DirectlyDerivedTypes
+ .Select(derivedType => GetRelationshipsInTypeOrDerived(derivedType, publicName))
+ .SelectMany(relationshipsInDerivedType => relationshipsInDerivedType))
+ {
+ relationshipsInDerivedTypes.Add(relationshipInDerivedType);
+ }
+
+ return relationshipsInDerivedTypes;
+ }
+
+ internal bool IsPartOfTypeHierarchy()
+ {
+ return BaseType != null || DirectlyDerivedTypes.Any();
+ }
+
public override string ToString()
{
return PublicName;
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
index 6406ba17ff..11320a7abc 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs
@@ -14,21 +14,8 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute
{
private protected static readonly CollectionConverter CollectionConverter = new();
- ///
- /// The CLR type in which this relationship is declared.
- ///
- internal Type? LeftClrType { get; set; }
-
- ///
- /// The CLR type this relationship points to. In the case of a relationship, this value will be the collection element
- /// type.
- ///
- ///
- /// Tags { get; set; } // RightClrType: typeof(Tag)
- /// ]]>
- ///
- internal Type? RightClrType { get; set; }
+ // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable.
+ private ResourceType? _rightType;
///
/// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed
@@ -52,15 +39,28 @@ public abstract class RelationshipAttribute : ResourceFieldAttribute
public PropertyInfo? InverseNavigationProperty { get; set; }
///
- /// The containing resource type in which this relationship is declared.
+ /// The containing resource type in which this relationship is declared. Identical to .
///
- public ResourceType LeftType { get; internal set; } = null!;
+ public ResourceType LeftType => Type;
///
/// The resource type this relationship points to. In the case of a relationship, this value will be the collection
/// element type.
///
- public ResourceType RightType { get; internal set; } = null!;
+ ///
+ /// Tags { get; set; } // RightType: Tag
+ /// ]]>
+ ///
+ public ResourceType RightType
+ {
+ get => _rightType!;
+ internal set
+ {
+ ArgumentGuard.NotNull(value, nameof(value));
+ _rightType = value;
+ }
+ }
///
/// Configures which links to write in the relationship-level links object for this relationship. Defaults to ,
@@ -91,12 +91,11 @@ public override bool Equals(object? obj)
var other = (RelationshipAttribute)obj;
- return LeftClrType == other.LeftClrType && RightClrType == other.RightClrType && Links == other.Links && CanInclude == other.CanInclude &&
- base.Equals(other);
+ return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other);
}
public override int GetHashCode()
{
- return HashCode.Combine(LeftClrType, RightClrType, Links, CanInclude, base.GetHashCode());
+ return HashCode.Combine(_rightType?.ClrType, Links, CanInclude, base.GetHashCode());
}
}
diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
index c7a44f59c8..599b17a42a 100644
--- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
+++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using JetBrains.Annotations;
+using JsonApiDotNetCore.Configuration;
// ReSharper disable NonReadonlyMemberInGetHashCode
@@ -15,6 +16,7 @@ public abstract class ResourceFieldAttribute : Attribute
// These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable.
private string? _publicName;
private PropertyInfo? _property;
+ private ResourceType? _type;
///
/// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name.
@@ -46,6 +48,19 @@ internal set
}
}
+ ///
+ /// The containing resource type in which this field is declared.
+ ///
+ public ResourceType Type
+ {
+ get => _type!;
+ internal set
+ {
+ ArgumentGuard.NotNull(value, nameof(value));
+ _type = value;
+ }
+ }
+
///
/// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the
/// specified resource instance.
diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs
index cbbc702d0c..9cb463ab10 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs
@@ -96,7 +96,7 @@ private void AssertLocalIdIsAssigned(IIdentifiable resource)
{
if (resource.LocalId != null)
{
- ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType());
+ ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType());
_localIdTracker.GetValue(resource.LocalId, resourceType);
}
}
diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
index 4a26e36710..6524252abf 100644
--- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
+++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs
@@ -140,7 +140,7 @@ private void AssignStringId(IIdentifiable resource)
{
if (resource.LocalId != null)
{
- ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetType());
+ ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType());
resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType);
}
}
diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
index 9ccce6c60a..a98b74c3fe 100644
--- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
+++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs
@@ -54,7 +54,7 @@ ResourceType GetResourceType()
/// (TResource resource) => new { resource.Attribute1, resource.Relationship2 }
/// ]]>
///
- IReadOnlyCollection GetFields(Expression> selector)
+ IReadOnlyCollection GetFields(Expression> selector)
where TResource : class, IIdentifiable;
///
@@ -68,7 +68,7 @@ IReadOnlyCollection GetFields(Expression new { resource.attribute1, resource.Attribute2 }
/// ]]>
///
- IReadOnlyCollection GetAttributes(Expression> selector)
+ IReadOnlyCollection GetAttributes(Expression> selector)
where TResource : class, IIdentifiable;
///
@@ -82,6 +82,6 @@ IReadOnlyCollection GetAttributes(Expression new { resource.Relationship1, resource.Relationship2 }
/// ]]>
///
- IReadOnlyCollection GetRelationships(Expression> selector)
+ IReadOnlyCollection GetRelationships(Expression> selector)
where TResource : class, IIdentifiable;
}
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
index d5acc8a1f9..d693fa2c3c 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs
@@ -42,7 +42,7 @@ public ResourceType GetResourceType(string publicName)
if (resourceType == null)
{
- throw new InvalidOperationException($"Resource type '{publicName}' does not exist.");
+ throw new InvalidOperationException($"Resource type '{publicName}' does not exist in the resource graph.");
}
return resourceType;
@@ -63,7 +63,7 @@ public ResourceType GetResourceType(Type resourceClrType)
if (resourceType == null)
{
- throw new InvalidOperationException($"Resource of type '{resourceClrType.Name}' does not exist.");
+ throw new InvalidOperationException($"Type '{resourceClrType}' does not exist in the resource graph.");
}
return resourceType;
@@ -91,7 +91,7 @@ public ResourceType GetResourceType()
}
///
- public IReadOnlyCollection GetFields(Expression> selector)
+ public IReadOnlyCollection GetFields(Expression> selector)
where TResource : class, IIdentifiable
{
ArgumentGuard.NotNull(selector, nameof(selector));
@@ -100,7 +100,7 @@ public IReadOnlyCollection GetFields(Expressi
}
///
- public IReadOnlyCollection GetAttributes(Expression> selector)
+ public IReadOnlyCollection GetAttributes(Expression> selector)
where TResource : class, IIdentifiable
{
ArgumentGuard.NotNull(selector, nameof(selector));
@@ -109,7 +109,7 @@ public IReadOnlyCollection GetAttributes(Expression
- public IReadOnlyCollection GetRelationships(Expression> selector)
+ public IReadOnlyCollection GetRelationships(Expression> selector)
where TResource : class, IIdentifiable
{
ArgumentGuard.NotNull(selector, nameof(selector));
@@ -117,7 +117,7 @@ public IReadOnlyCollection GetRelationships(Ex
return FilterFields(selector);
}
- private IReadOnlyCollection FilterFields(Expression> selector)
+ private IReadOnlyCollection FilterFields(Expression> selector)
where TResource : class, IIdentifiable
where TField : ResourceFieldAttribute
{
@@ -157,7 +157,7 @@ private IReadOnlyCollection GetFieldsOfType()
return (IReadOnlyCollection)resourceType.Fields;
}
- private IEnumerable ToMemberNames(Expression> selector)
+ private IEnumerable ToMemberNames(Expression> selector)
{
Expression selectorBody = RemoveConvert(selector.Body);
diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
index 1352160f11..428b14d32e 100644
--- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs
@@ -43,21 +43,103 @@ public IResourceGraph Build()
var resourceGraph = new ResourceGraph(resourceTypes);
- foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships))
+ SetFieldTypes(resourceGraph);
+ SetRelationshipTypes(resourceGraph);
+ SetDirectlyDerivedTypes(resourceGraph);
+ ValidateFieldsInDerivedTypes(resourceGraph);
+
+ return resourceGraph;
+ }
+
+ private static void SetFieldTypes(ResourceGraph resourceGraph)
+ {
+ foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields))
{
- relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!);
- ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!);
+ field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!);
+ }
+ }
+
+ private static void SetRelationshipTypes(ResourceGraph resourceGraph)
+ {
+ foreach (RelationshipAttribute relationship in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships))
+ {
+ Type rightClrType = relationship is HasOneAttribute
+ ? relationship.Property.PropertyType
+ : relationship.Property.PropertyType.GetGenericArguments()[0];
+
+ ResourceType? rightType = resourceGraph.FindResourceType(rightClrType);
if (rightType == null)
{
- throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " +
- $"'{relationship.RightClrType}', which was not added to the resource graph.");
+ throw new InvalidConfigurationException($"Resource type '{relationship.LeftType.ClrType}' depends on " +
+ $"'{rightClrType}', which was not added to the resource graph.");
}
relationship.RightType = rightType;
}
+ }
- return resourceGraph;
+ private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph)
+ {
+ Dictionary> directlyDerivedTypesPerBaseType = new();
+
+ foreach (ResourceType resourceType in resourceGraph.GetResourceTypes())
+ {
+ ResourceType? baseType = resourceGraph.FindResourceType(resourceType.ClrType.BaseType!);
+
+ if (baseType != null)
+ {
+ resourceType.BaseType = baseType;
+
+ if (!directlyDerivedTypesPerBaseType.ContainsKey(baseType))
+ {
+ directlyDerivedTypesPerBaseType[baseType] = new HashSet();
+ }
+
+ directlyDerivedTypesPerBaseType[baseType].Add(resourceType);
+ }
+ }
+
+ foreach ((ResourceType baseType, HashSet directlyDerivedTypes) in directlyDerivedTypesPerBaseType)
+ {
+ baseType.DirectlyDerivedTypes = directlyDerivedTypes;
+ }
+ }
+
+ private void ValidateFieldsInDerivedTypes(ResourceGraph resourceGraph)
+ {
+ foreach (ResourceType resourceType in resourceGraph.GetResourceTypes())
+ {
+ if (resourceType.BaseType != null)
+ {
+ ValidateAttributesInDerivedType(resourceType);
+ ValidateRelationshipsInDerivedType(resourceType);
+ }
+ }
+ }
+
+ private static void ValidateAttributesInDerivedType(ResourceType resourceType)
+ {
+ foreach (AttrAttribute attribute in resourceType.BaseType!.Attributes)
+ {
+ if (resourceType.FindAttributeByPublicName(attribute.PublicName) == null)
+ {
+ throw new InvalidConfigurationException($"Attribute '{attribute.PublicName}' from base type " +
+ $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'.");
+ }
+ }
+ }
+
+ private static void ValidateRelationshipsInDerivedType(ResourceType resourceType)
+ {
+ foreach (RelationshipAttribute relationship in resourceType.BaseType!.Relationships)
+ {
+ if (resourceType.FindRelationshipByPublicName(relationship.PublicName) == null)
+ {
+ throw new InvalidConfigurationException($"Relationship '{relationship.PublicName}' from base type " +
+ $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'.");
+ }
+ }
}
public ResourceGraphBuilder Add(DbContext dbContext)
@@ -221,8 +303,6 @@ private IReadOnlyCollection GetRelationships(Type resourc
{
relationship.Property = property;
SetPublicName(relationship, property);
- relationship.LeftClrType = resourceClrType;
- relationship.RightClrType = GetRelationshipType(relationship, property);
IncludeField(relationshipsByName, relationship);
}
@@ -237,14 +317,6 @@ private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property)
field.PublicName ??= FormatPropertyName(property);
}
- private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property)
- {
- ArgumentGuard.NotNull(relationship, nameof(relationship));
- ArgumentGuard.NotNull(property, nameof(property));
-
- return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0];
- }
-
private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0)
{
AssertNoInfiniteRecursion(recursionDepth);
diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
index 64a15e2eda..64a99021d4 100644
--- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
+++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs
@@ -111,7 +111,7 @@ private static void RegisterTypeForUnboundInterfaces(IServiceCollection serviceC
if (!seenCompatibleInterface)
{
- throw new InvalidConfigurationException($"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces.");
+ throw new InvalidConfigurationException($"Type '{implementationType}' does not implement any of the expected JsonApiDotNetCore interfaces.");
}
}
diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
index debcfa5c6f..a948d67edc 100644
--- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
+++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs
@@ -148,7 +148,8 @@ protected virtual void ValidateModelState(IList operations)
Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry);
throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors,
- _resourceGraph, (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetType() : null);
+ _resourceGraph,
+ (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetClrType() : null);
}
}
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs
index be8a22abee..980a7846bc 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs
@@ -34,14 +34,24 @@ public override TResult Accept(QueryExpressionVisitor constant.ToString()).OrderBy(value => value)));
+ builder.Append(string.Join(",", Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).OrderBy(value => value)));
builder.Append(')');
return builder.ToString();
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs
index 4c858bd743..9bf1c3bde8 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs
@@ -33,6 +33,11 @@ public override string ToString()
return $"{Operator.ToString().Camelize()}({Left},{Right})";
}
+ public override string ToFullString()
+ {
+ return $"{Operator.ToString().Camelize()}({Left.ToFullString()},{Right.ToFullString()})";
+ }
+
public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj))
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs
index 1bab96eaab..5de89ead7c 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs
@@ -28,6 +28,11 @@ public override string ToString()
return $"{Keywords.Count}({TargetCollection})";
}
+ public override string ToFullString()
+ {
+ return $"{Keywords.Count}({TargetCollection.ToFullString()})";
+ }
+
public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj))
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs
index d1376a3091..c5387106d6 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs
@@ -27,16 +27,26 @@ public override TResult Accept(QueryExpressionVisitor GetRelationshipChains(I
return converter.Chains;
}
- ///
- /// Converts a set of relationship chains into a tree of inclusions.
- ///
- ///
- /// Input chains: Blog,
- /// Article -> Revisions -> Author
- /// ]]>
Output tree:
- ///
- ///
- public IncludeExpression FromRelationshipChains(IEnumerable chains)
- {
- ArgumentGuard.NotNull(chains, nameof(chains));
-
- IImmutableSet elements = ConvertChainsToElements(chains);
- return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty;
- }
-
- private static IImmutableSet ConvertChainsToElements(IEnumerable chains)
- {
- var rootNode = new MutableIncludeNode(null!);
-
- foreach (ResourceFieldChainExpression chain in chains)
- {
- ConvertChainToElement(chain, rootNode);
- }
-
- return rootNode.Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet();
- }
-
- private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode)
- {
- MutableIncludeNode currentNode = rootNode;
-
- foreach (RelationshipAttribute relationship in chain.Fields.OfType())
- {
- if (!currentNode.Children.ContainsKey(relationship))
- {
- currentNode.Children[relationship] = new MutableIncludeNode(relationship);
- }
-
- currentNode = currentNode.Children[relationship];
- }
- }
-
private sealed class IncludeToChainsConverter : QueryExpressionVisitor
internal sealed class ResourceFieldChainResolver
{
+ private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new();
+
+ ///
+ /// Resolves a chain of to-one relationships.
+ /// author
+ ///
+ /// author.address.country
+ ///
+ ///
+ public IImmutableList ResolveToOneChain(ResourceType resourceType, string path,
+ Action? validateCallback = null)
+ {
+ ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder();
+ ResourceType nextResourceType = resourceType;
+
+ foreach (string publicName in path.Split("."))
+ {
+ RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled);
+
+ validateCallback?.Invoke(toOneRelationship, nextResourceType, path);
+
+ chainBuilder.Add(toOneRelationship);
+ nextResourceType = toOneRelationship.RightType;
+ }
+
+ return chainBuilder.ToImmutable();
+ }
+
///
/// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments
///
@@ -22,7 +50,7 @@ public IImmutableList ResolveToManyChain(ResourceType re
foreach (string publicName in publicNameParts[..^1])
{
- RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path);
+ RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled);
validateCallback?.Invoke(relationship, nextResourceType, path);
@@ -31,7 +59,7 @@ public IImmutableList ResolveToManyChain(ResourceType re
}
string lastName = publicNameParts[^1];
- RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path);
+ RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled);
validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path);
@@ -59,7 +87,7 @@ public IImmutableList ResolveRelationshipChain(ResourceT
foreach (string publicName in path.Split("."))
{
- RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path);
+ RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled);
validateCallback?.Invoke(relationship, nextResourceType, path);
@@ -78,7 +106,7 @@ public IImmutableList ResolveRelationshipChain(ResourceT
/// name
///
public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path,
- Action? validateCallback = null)
+ FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null)
{
ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder();
@@ -87,7 +115,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute
foreach (string publicName in publicNameParts[..^1])
{
- RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path);
+ RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement);
validateCallback?.Invoke(toOneRelationship, nextResourceType, path);
@@ -96,7 +124,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute
}
string lastName = publicNameParts[^1];
- AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path);
+ AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path, inheritanceRequirement);
validateCallback?.Invoke(lastAttribute, nextResourceType, path);
@@ -114,7 +142,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute
///
///
public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path,
- Action? validateCallback = null)
+ FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null)
{
ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder();
@@ -123,7 +151,7 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re
foreach (string publicName in publicNameParts[..^1])
{
- RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path);
+ RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement);
validateCallback?.Invoke(toOneRelationship, nextResourceType, path);
@@ -133,7 +161,7 @@ public IImmutableList ResolveToOneChainEndingInToMany(Re
string lastName = publicNameParts[^1];
- RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path);
+ RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, inheritanceRequirement);
validateCallback?.Invoke(toManyRelationship, nextResourceType, path);
@@ -160,7 +188,7 @@ public IImmutableList ResolveToOneChainEndingInAttribute
foreach (string publicName in publicNameParts[..^1])
{
- RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path);
+ RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled);
validateCallback?.Invoke(toOneRelationship, nextResourceType, path);
@@ -173,9 +201,10 @@ public IImmutableList ResolveToOneChainEndingInAttribute
if (lastField is HasManyAttribute)
{
- throw new QueryParseException(path == lastName
- ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'."
- : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource type '{nextResourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Field, lastName, path, nextResourceType,
+ "an attribute or a to-one relationship");
+
+ throw new QueryParseException(message);
}
validateCallback?.Invoke(lastField, nextResourceType, path);
@@ -184,60 +213,75 @@ public IImmutableList ResolveToOneChainEndingInAttribute
return chainBuilder.ToImmutable();
}
- private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path)
+ private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path,
+ FieldChainInheritanceRequirement inheritanceRequirement)
{
- RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName);
+ IReadOnlyCollection relationships = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled
+ ? resourceType.FindRelationshipByPublicName(publicName)?.AsArray() ?? Array.Empty()
+ : resourceType.GetRelationshipsInTypeOrDerived(publicName);
- if (relationship == null)
+ if (relationships.Count == 0)
{
- throw new QueryParseException(path == publicName
- ? $"Relationship '{publicName}' does not exist on resource type '{resourceType.PublicName}'."
- : $"Relationship '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Relationship, publicName, path, resourceType, inheritanceRequirement);
+ throw new QueryParseException(message);
}
- return relationship;
+ if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && relationships.Count > 1)
+ {
+ string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Relationship, publicName, path);
+ throw new QueryParseException(message);
+ }
+
+ return relationships.First();
}
- private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path)
+ private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path,
+ FieldChainInheritanceRequirement inheritanceRequirement)
{
- RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path);
+ RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement);
if (relationship is not HasManyAttribute)
{
- throw new QueryParseException(path == publicName
- ? $"Relationship '{publicName}' must be a to-many relationship on resource type '{resourceType.PublicName}'."
- : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource type '{resourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-many relationship");
+ throw new QueryParseException(message);
}
return relationship;
}
- private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path)
+ private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path,
+ FieldChainInheritanceRequirement inheritanceRequirement)
{
- RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path);
+ RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement);
if (relationship is not HasOneAttribute)
{
- throw new QueryParseException(path == publicName
- ? $"Relationship '{publicName}' must be a to-one relationship on resource type '{resourceType.PublicName}'."
- : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource type '{resourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-one relationship");
+ throw new QueryParseException(message);
}
return relationship;
}
- private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path)
+ private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path, FieldChainInheritanceRequirement inheritanceRequirement)
{
- AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName);
+ IReadOnlyCollection attributes = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled
+ ? resourceType.FindAttributeByPublicName(publicName)?.AsArray() ?? Array.Empty()
+ : resourceType.GetAttributesInTypeOrDerived(publicName);
- if (attribute == null)
+ if (attributes.Count == 0)
{
- throw new QueryParseException(path == publicName
- ? $"Attribute '{publicName}' does not exist on resource type '{resourceType.PublicName}'."
- : $"Attribute '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Attribute, publicName, path, resourceType, inheritanceRequirement);
+ throw new QueryParseException(message);
}
- return attribute;
+ if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && attributes.Count > 1)
+ {
+ string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Attribute, publicName, path);
+ throw new QueryParseException(message);
+ }
+
+ return attributes.First();
}
public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path)
@@ -246,9 +290,10 @@ public ResourceFieldAttribute GetField(string publicName, ResourceType resourceT
if (field == null)
{
- throw new QueryParseException(path == publicName
- ? $"Field '{publicName}' does not exist on resource type '{resourceType.PublicName}'."
- : $"Field '{publicName}' in '{path}' does not exist on resource type '{resourceType.PublicName}'.");
+ string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Field, publicName, path, resourceType,
+ FieldChainInheritanceRequirement.Disabled);
+
+ throw new QueryParseException(message);
}
return field;
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs
index 38a263e063..84782c2b3e 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs
@@ -74,14 +74,40 @@ protected SortElementExpression ParseSortElement()
protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements)
{
+ // An attribute or relationship name usually matches a single field, even when overridden in derived types.
+ // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints:
+ //
+ // public abstract class ShoppingBasket : Identifiable
+ // {
+ // }
+ //
+ // public sealed class SilverShoppingBasket : ShoppingBasket
+ // {
+ // [Attr]
+ // public short BonusPoints { get; set; }
+ // }
+ //
+ // public sealed class PlatinumShoppingBasket : ShoppingBasket
+ // {
+ // [Attr]
+ // public long BonusPoints { get; set; }
+ // }
+ //
+ // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends
+ // on which attribute is used.
+ //
+ // Because there is no syntax to pick one, we fail with an error. We could add such optional upcast syntax
+ // (which would be required in this case) in the future to make it work, if desired.
+
if (chainRequirements == FieldChainRequirements.EndsInToMany)
{
- return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path);
+ return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch);
}
if (chainRequirements == FieldChainRequirements.EndsInAttribute)
{
- return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback);
+ return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch,
+ _validateSingleFieldCallback);
}
throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'.");
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs
index e7c96d21d5..b23dfdfea1 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs
@@ -37,14 +37,14 @@ private ResourceType ParseSparseFieldTarget()
EatSingleCharacterToken(TokenKind.OpenBracket);
- ResourceType resourceType = ParseResourceName();
+ ResourceType resourceType = ParseResourceType();
EatSingleCharacterToken(TokenKind.CloseBracket);
return resourceType;
}
- private ResourceType ParseResourceName()
+ private ResourceType ParseResourceType()
{
if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text)
{
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs
index 75b6952f5a..f73cbd3418 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs
@@ -6,6 +6,7 @@ public enum TokenKind
CloseParen,
OpenBracket,
CloseBracket,
+ Period,
Comma,
Colon,
Minus,
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
index f7b95c3c86..00181a23a7 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs
@@ -167,7 +167,7 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R
Filter = GetFilter(expressionsInTopScope, resourceType),
Sort = GetSort(expressionsInTopScope, resourceType),
Pagination = topPagination,
- Projection = GetProjectionForSparseAttributeSet(resourceType)
+ Selection = GetSelectionForSparseAttributeSet(resourceType)
};
}
@@ -203,9 +203,10 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet<
foreach (IncludeElementExpression includeElement in includeElementsEvaluated)
{
- parentLayer.Projection ??= new Dictionary();
+ parentLayer.Selection ??= new FieldSelection();
+ FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(parentLayer.ResourceType);
- if (!parentLayer.Projection.ContainsKey(includeElement.Relationship))
+ if (!selectors.ContainsField(includeElement.Relationship))
{
var relationshipChain = new List(parentRelationshipChain)
{
@@ -232,10 +233,10 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet<
Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null,
Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null,
Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null,
- Projection = GetProjectionForSparseAttributeSet(resourceType)
+ Selection = GetSelectionForSparseAttributeSet(resourceType)
};
- parentLayer.Projection.Add(includeElement.Relationship, child);
+ selectors.IncludeRelationship(includeElement.Relationship, child);
IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints);
@@ -278,18 +279,15 @@ public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceTyp
if (fieldSelection == TopFieldSelection.OnlyIdAttribute)
{
- queryLayer.Projection = new Dictionary
- {
- [idAttribute] = null
- };
+ queryLayer.Selection = new FieldSelection();
+ FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType);
+ selectors.IncludeAttribute(idAttribute);
}
- else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null)
+ else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null)
{
// Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row.
- while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute))
- {
- queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute));
- }
+ FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType);
+ selectors.RemoveAttributes();
}
return queryLayer;
@@ -301,17 +299,23 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryRes
ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType));
QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType);
- secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceType);
+ secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType);
secondaryLayer.Include = null;
return secondaryLayer;
}
- private IDictionary GetProjectionForRelationship(ResourceType secondaryResourceType)
+#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+ private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType)
+#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type
{
+ var selection = new FieldSelection();
+ FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType);
+
IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType);
+ selectors.IncludeAttributes(secondaryAttributeSet);
- return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null);
+ return selection;
}
///
@@ -325,12 +329,12 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer,
IncludeExpression? innerInclude = secondaryLayer.Include;
secondaryLayer.Include = null;
- IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType);
-
- Dictionary primaryProjection =
- primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null);
+ var primarySelection = new FieldSelection();
+ FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType);
- primaryProjection[relationship] = secondaryLayer;
+ IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType);
+ primarySelectors.IncludeAttributes(primaryAttributeSet);
+ primarySelectors.IncludeRelationship(relationship, secondaryLayer);
FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType);
AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
@@ -339,7 +343,7 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer,
{
Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship),
Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter),
- Projection = primaryProjection
+ Selection = primarySelection
};
}
@@ -387,7 +391,7 @@ public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType
primaryLayer.Sort = null;
primaryLayer.Pagination = null;
primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter);
- primaryLayer.Projection = null;
+ primaryLayer.Selection = null;
return primaryLayer;
}
@@ -418,19 +422,20 @@ public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relati
AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType);
- object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray();
+ HashSet
public abstract class QueryClauseBuilder : QueryExpressionVisitor
{
- protected LambdaScope LambdaScope { get; }
+ protected LambdaScope LambdaScope { get; private set; }
protected QueryClauseBuilder(LambdaScope lambdaScope)
{
@@ -59,28 +60,48 @@ public override Expression VisitCount(CountExpression expression, TArgument argu
}
public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
- {
- string[] components = expression.Fields.Select(field => field.Property.Name).ToArray();
-
- return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components);
- }
-
- private static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IEnumerable components)
{
MemberExpression? property = null;
- foreach (string propertyName in components)
+ foreach (ResourceFieldAttribute field in expression.Fields)
{
- Type parentType = property == null ? source.Type : property.Type;
+ Expression parentAccessor = property ?? LambdaScope.Accessor;
+ Type propertyType = field.Property.DeclaringType!;
+ string propertyName = field.Property.Name;
+
+ bool requiresUpCast = parentAccessor.Type != propertyType && parentAccessor.Type.IsAssignableFrom(propertyType);
+ Type parentType = requiresUpCast ? propertyType : parentAccessor.Type;
if (parentType.GetProperty(propertyName) == null)
{
throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'.");
}
- property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName);
+ property = requiresUpCast
+ ? Expression.MakeMemberAccess(Expression.Convert(parentAccessor, propertyType), field.Property)
+ : Expression.Property(parentAccessor, propertyName);
}
return property!;
}
+
+ protected TResult WithLambdaScopeAccessor(Expression accessorExpression, Func action)
+ {
+ ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression));
+ ArgumentGuard.NotNull(action, nameof(action));
+
+ LambdaScope backupScope = LambdaScope;
+
+ try
+ {
+ using (LambdaScope = LambdaScope.WithAccessor(accessorExpression))
+ {
+ return action();
+ }
+ }
+ finally
+ {
+ LambdaScope = backupScope;
+ }
+ }
}
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs
index 7ce50c02f1..d571ac1dce 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs
@@ -3,7 +3,6 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Resources;
-using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.EntityFrameworkCore.Metadata;
namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding;
@@ -67,9 +66,9 @@ public virtual Expression ApplyQuery(QueryLayer layer)
expression = ApplyPagination(expression, layer.Pagination);
}
- if (!layer.Projection.IsNullOrEmpty())
+ if (layer.Selection is { IsEmpty: false })
{
- expression = ApplyProjection(expression, layer.Projection, layer.ResourceType);
+ expression = ApplySelection(expression, layer.Selection, layer.ResourceType);
}
return expression;
@@ -107,11 +106,11 @@ protected virtual Expression ApplyPagination(Expression source, PaginationExpres
return builder.ApplySkipTake(pagination);
}
- protected virtual Expression ApplyProjection(Expression source, IDictionary projection, ResourceType resourceType)
+ protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType)
{
using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType);
var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory);
- return builder.ApplySelect(projection, resourceType);
+ return builder.ApplySelect(selection, resourceType);
}
}
diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
index 3931cdc180..fbf3f8ebca 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs
@@ -16,6 +16,8 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding;
[PublicAPI]
public class SelectClauseBuilder : QueryClauseBuilder
{
+ private static readonly MethodInfo TypeGetTypeMethod = typeof(object).GetMethod("GetType")!;
+ private static readonly MethodInfo TypeOpEqualityMethod = typeof(Type).GetMethod("op_Equality")!;
private static readonly CollectionConverter CollectionConverter = new();
private static readonly ConstantExpression NullConstant = Expression.Constant(null);
@@ -42,66 +44,111 @@ public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel en
_resourceFactory = resourceFactory;
}
- public Expression ApplySelect(IDictionary selectors, ResourceType resourceType)
+ public Expression ApplySelect(FieldSelection selection, ResourceType resourceType)
{
- ArgumentGuard.NotNull(selectors, nameof(selectors));
+ ArgumentGuard.NotNull(selection, nameof(selection));
- if (!selectors.Any())
- {
- return _source;
- }
-
- Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceType, LambdaScope, false);
+ Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, LambdaScope, false);
LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter);
return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda);
}
- private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceType resourceType,
- LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull)
+ private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope,
+ bool lambdaAccessorRequiresTestForNull)
{
- ICollection propertySelectors = ToPropertySelectors(selectors, resourceType, lambdaScope.Accessor.Type);
+ IEntityType entityType = _entityModel.FindEntityType(resourceType.ClrType)!;
+ IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray();
- MemberBinding[] propertyAssignments =
- propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray();
-
- NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type);
- Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments);
+ Expression bodyInitializer = concreteEntityTypes.Length > 1
+ ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope)
+ : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope);
if (!lambdaAccessorRequiresTestForNull)
{
- return memberInit;
+ return bodyInitializer;
}
- return TestForNull(lambdaScope.Accessor, memberInit);
+ return TestForNull(lambdaScope.Accessor, bodyInitializer);
}
- private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors,
- ResourceType resourceType, Type elementType)
+ private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType,
+ IEnumerable concreteEntityTypes, LambdaScope lambdaScope)
{
- var propertySelectors = new Dictionary();
+ ISet resourceTypes = selection.GetResourceTypes();
+ Expression rootCondition = lambdaScope.Accessor;
+
+ foreach (IEntityType entityType in concreteEntityTypes)
+ {
+ ResourceType? resourceType = resourceTypes.SingleOrDefault(type => type.ClrType == entityType.ClrType);
- // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties.
- bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector =>
- selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null);
+ if (resourceType != null)
+ {
+ FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType);
- // Only selecting relationships implicitly means to select all attributes too.
- bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute);
+ if (!fieldSelectors.IsEmpty)
+ {
+ ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType);
- if (includesReadOnlyAttribute || containsOnlyRelationships)
- {
- IncludeAllProperties(elementType, propertySelectors);
+ MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope))
+ .Cast().ToArray();
+
+ NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType);
+ MemberInitExpression memberInit = Expression.MemberInit(createInstance, propertyAssignments);
+ UnaryExpression castToBaseType = Expression.Convert(memberInit, baseResourceType.ClrType);
+
+ BinaryExpression typeCheck = CreateRuntimeTypeCheck(lambdaScope, entityType.ClrType);
+ rootCondition = Expression.Condition(typeCheck, castToBaseType, rootCondition);
+ }
+ }
}
- IncludeFieldSelection(resourceFieldSelectors, propertySelectors);
+ return rootCondition;
+ }
+
+ private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, Type concreteClrType)
+ {
+ // Emitting "resource.GetType() == typeof(Article)" instead of "resource is Article" so we don't need to check for most-derived
+ // types first. This way, we can fallback to "anything else" at the end without worrying about order.
+
+ Expression concreteTypeConstant = concreteClrType.CreateTupleAccessExpressionForConstant(typeof(Type));
+ MethodCallExpression getTypeCall = Expression.Call(lambdaScope.Accessor, TypeGetTypeMethod);
+ return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod);
+ }
+
+ private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope)
+ {
+ FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType);
+ ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type);
+
+ MemberBinding[] propertyAssignments =
+ propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray();
+
+ NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type);
+ return Expression.MemberInit(createInstance, propertyAssignments);
+ }
+
+ private ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType)
+ {
+ var propertySelectors = new Dictionary();
+
+ if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships)
+ {
+ // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties.
+ // And only selecting relationships implicitly means to select all attributes too.
+
+ IncludeAllAttributes(elementType, propertySelectors);
+ }
+
+ IncludeFields(fieldSelectors, propertySelectors);
IncludeEagerLoads(resourceType, propertySelectors);
return propertySelectors.Values;
}
- private void IncludeAllProperties(Type elementType, Dictionary propertySelectors)
+ private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors)
{
IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray();
@@ -113,10 +160,9 @@ private void IncludeAllProperties(Type elementType, Dictionary resourceFieldSelectors,
- Dictionary propertySelectors)
+ private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors)
{
- foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in resourceFieldSelectors)
+ foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in fieldSelectors)
{
var propertySelector = new PropertySelector(resourceField.Property, queryLayer);
IncludeWritableProperty(propertySelector, propertySelectors);
@@ -146,21 +192,26 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary Visit(expression.Child, argument));
+
+ return Expression.AndAlso(typeCheck, filter);
+ }
+
public override Expression VisitMatchText(MatchTextExpression expression, Type? argument)
{
Expression property = Visit(expression.TargetAttribute, argument);
diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs
index be1d657cfe..c460560a33 100644
--- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs
@@ -2,7 +2,6 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Expressions;
-using JsonApiDotNetCore.Resources.Annotations;
namespace JsonApiDotNetCore.Queries;
@@ -18,7 +17,7 @@ public sealed class QueryLayer
public FilterExpression? Filter { get; set; }
public SortExpression? Sort { get; set; }
public PaginationExpression? Pagination { get; set; }
- public IDictionary? Projection { get; set; }
+ public FieldSelection? Selection { get; set; }
public QueryLayer(ResourceType resourceType)
{
@@ -32,92 +31,41 @@ public override string ToString()
var builder = new StringBuilder();
var writer = new IndentingStringWriter(builder);
- WriteLayer(writer, this);
+ WriteLayer(writer, null);
return builder.ToString();
}
- private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string? prefix = null)
+ internal void WriteLayer(IndentingStringWriter writer, string? prefix)
{
- writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{layer.ResourceType.ClrType.Name}>");
+ writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{ResourceType.ClrType.Name}>");
using (writer.Indent())
{
- if (layer.Include != null)
+ if (Include != null)
{
- writer.WriteLine($"{nameof(Include)}: {layer.Include}");
+ writer.WriteLine($"{nameof(Include)}: {Include}");
}
- if (layer.Filter != null)
+ if (Filter != null)
{
- writer.WriteLine($"{nameof(Filter)}: {layer.Filter}");
+ writer.WriteLine($"{nameof(Filter)}: {Filter}");
}
- if (layer.Sort != null)
+ if (Sort != null)
{
- writer.WriteLine($"{nameof(Sort)}: {layer.Sort}");
+ writer.WriteLine($"{nameof(Sort)}: {Sort}");
}
- if (layer.Pagination != null)
+ if (Pagination != null)
{
- writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}");
+ writer.WriteLine($"{nameof(Pagination)}: {Pagination}");
}
- if (!layer.Projection.IsNullOrEmpty())
+ if (Selection is { IsEmpty: false })
{
- writer.WriteLine(nameof(Projection));
-
- using (writer.Indent())
- {
- foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in layer.Projection)
- {
- if (nextLayer == null)
- {
- writer.WriteLine(field.ToString());
- }
- else
- {
- WriteLayer(writer, nextLayer, $"{field.PublicName}: ");
- }
- }
- }
- }
- }
- }
-
- private sealed class IndentingStringWriter : IDisposable
- {
- private readonly StringBuilder _builder;
- private int _indentDepth;
-
- public IndentingStringWriter(StringBuilder builder)
- {
- _builder = builder;
- }
-
- public void WriteLine(string? line)
- {
- if (_indentDepth > 0)
- {
- _builder.Append(new string(' ', _indentDepth * 2));
- }
-
- _builder.AppendLine(line);
- }
-
- public IndentingStringWriter Indent()
- {
- WriteLine("{");
- _indentDepth++;
- return this;
- }
-
- public void Dispose()
- {
- if (_indentDepth > 0)
- {
- _indentDepth--;
- WriteLine("}");
+ writer.WriteLine(nameof(Selection));
+ Selection.WriteSelection(writer);
}
}
}
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
index 51b815c2ef..6c8bfa2934 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs
@@ -6,7 +6,6 @@
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Queries.Internal.Parsing;
-using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.Extensions.Primitives;
namespace JsonApiDotNetCore.QueryStrings.Internal;
@@ -18,7 +17,6 @@ public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIn
private readonly IncludeParser _includeParser;
private IncludeExpression? _includeExpression;
- private string? _lastParameterName;
public bool AllowEmptyValue => false;
@@ -28,18 +26,7 @@ public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph
ArgumentGuard.NotNull(options, nameof(options));
_options = options;
- _includeParser = new IncludeParser(ValidateSingleRelationship);
- }
-
- protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceType resourceType, string path)
- {
- if (!relationship.CanInclude)
- {
- throw new InvalidQueryStringParameterException(_lastParameterName!, "Including the requested relationship is not allowed.",
- path == relationship.PublicName
- ? $"Including the relationship '{relationship.PublicName}' on '{resourceType.PublicName}' is not allowed."
- : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceType.PublicName}' is not allowed.");
- }
+ _includeParser = new IncludeParser();
}
///
@@ -59,8 +46,6 @@ public virtual bool CanRead(string parameterName)
///
public virtual void Read(string parameterName, StringValues parameterValue)
{
- _lastParameterName = parameterName;
-
try
{
_includeExpression = GetInclude(parameterValue);
diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs
index e9a9488b7b..cadbd658a8 100644
--- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs
+++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs
@@ -35,7 +35,7 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti
ArgumentGuard.NotNull(dbContext, nameof(dbContext));
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
- Type resourceClrType = identifiable.GetType();
+ Type resourceClrType = identifiable.GetClrType();
string? stringId = identifiable.StringId;
EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId));
diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
index 52c07c5244..50c9e07ea3 100644
--- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
+++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
@@ -155,14 +155,15 @@ protected virtual IQueryable GetAll()
}
///
- public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken)
+ public virtual Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken)
{
_traceWriter.LogMethodStart(new
{
+ resourceClrType,
id
});
- var resource = _resourceFactory.CreateInstance();
+ var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType);
resource.Id = id;
return Task.FromResult(resource);
@@ -305,10 +306,11 @@ protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribut
}
///
- public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken)
+ public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken)
{
_traceWriter.LogMethodStart(new
{
+ resourceFromDatabase,
id
});
@@ -316,7 +318,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
// This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker.
// If so, we'll reuse the tracked resource instead of this placeholder resource.
- var placeholderResource = _resourceFactory.CreateInstance();
+ TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance();
placeholderResource.Id = id;
await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken);
@@ -413,10 +415,12 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r
}
///
- public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken)
+ public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds,
+ CancellationToken cancellationToken)
{
_traceWriter.LogMethodStart(new
{
+ leftResource,
leftId,
rightResourceIds
});
@@ -427,18 +431,22 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet(leftId, relationship, rightResourceIds, cancellationToken);
+ // This enables OnAddToRelationshipAsync() or OnWritingAsync() to fetch the resource, which adds it to the change tracker.
+ // If so, we'll reuse the tracked resource instead of this placeholder resource.
+ TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance();
+ leftPlaceholderResource.Id = leftId;
+
+ await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken);
if (rightResourceIds.Any())
{
- var leftPlaceholderResource = _resourceFactory.CreateInstance();
- leftPlaceholderResource.Id = leftId;
-
var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource);
+ IEnumerable rightValueToStore = GetRightValueToStoreForAddToToMany(leftResourceTracked, relationship, rightResourceIds);
- await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIds, cancellationToken);
+ await UpdateRelationshipAsync(relationship, leftResourceTracked, rightValueToStore, cancellationToken);
await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken);
+ leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResourceTracked);
await SaveChangesAsync(cancellationToken);
@@ -446,6 +454,30 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIdsToAdd)
+ {
+ object? rightValueStored = relationship.GetValue(leftResource);
+
+ // @formatter:wrap_chained_method_calls chop_always
+ // @formatter:keep_existing_linebreaks true
+
+ HashSet rightResourceIdsStored = _collectionConverter
+ .ExtractResources(rightValueStored)
+ .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource))
+ .ToHashSet(IdentifiableComparer.Instance);
+
+ // @formatter:keep_existing_linebreaks restore
+ // @formatter:wrap_chained_method_calls restore
+
+ if (rightResourceIdsStored.Any())
+ {
+ rightResourceIdsStored.AddRange(rightResourceIdsToAdd);
+ return rightResourceIdsStored;
+ }
+
+ return rightResourceIdsToAdd;
+ }
+
///
public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds,
CancellationToken cancellationToken)
@@ -473,7 +505,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
// Make Entity Framework Core believe any additional resources added from ResourceDefinition already exist in database.
IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray();
- object? rightValueStored = relationship.GetValue(leftResource);
+ object? rightValueStored = relationship.GetValue(leftResourceTracked);
// @formatter:wrap_chained_method_calls chop_always
// @formatter:keep_existing_linebreaks true
@@ -488,9 +520,9 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour
// @formatter:wrap_chained_method_calls restore
rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType);
- relationship.SetValue(leftResource, rightValueStored);
+ relationship.SetValue(leftResourceTracked, rightValueStored);
- MarkRelationshipAsLoaded(leftResource, relationship);
+ MarkRelationshipAsLoaded(leftResourceTracked, relationship);
HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance);
rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove);
diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs
index 9654365121..149fef1cfb 100644
--- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs
+++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs
@@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Repositories;
public interface IResourceRepositoryAccessor
{
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
@@ -27,25 +27,25 @@ Task> GetAsync(QueryLayer queryLayer,
Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken);
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
- Task GetForCreateAsync(TId id, CancellationToken cancellationToken)
+ Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
@@ -53,11 +53,11 @@ Task UpdateAsync(TResource resourceFromRequest, TResource resourceFro
///
/// Invokes for the specified resource type.
///
- Task DeleteAsync(TId id, CancellationToken cancellationToken)
+ Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
@@ -65,11 +65,12 @@ Task SetRelationshipAsync(TResource leftResource, object? rightValue,
///
/// Invokes for the specified resource type.
///
- Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken)
+ Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds,
+ CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
///
- /// Invokes .
+ /// Invokes for the specified resource type.
///
Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken)
where TResource : class, IIdentifiable;
diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs
index ca0186d5b5..fb0267d18a 100644
--- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs
+++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs
@@ -23,7 +23,7 @@ public interface IResourceWriteRepository
///
/// This method can be overridden to assign resource-specific required relationships.
///
- Task GetForCreateAsync(TId id, CancellationToken cancellationToken);
+ Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken);
///
/// Creates a new resource in the underlying data store.
@@ -43,7 +43,7 @@ public interface IResourceWriteRepository
///
/// Deletes an existing resource from the underlying data store.
///
- Task DeleteAsync(TId id, CancellationToken cancellationToken);
+ Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken);
///
/// Performs a complete replacement of the relationship in the underlying data store.
@@ -53,7 +53,7 @@ public interface IResourceWriteRepository
///
/// Adds resources to a to-many relationship in the underlying data store.
///
- Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken);
+ Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken);
///
/// Removes resources from a to-many relationship in the underlying data store.
diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs
index 0ba96126a1..5f00cdf08d 100644
--- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs
+++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs
@@ -53,11 +53,11 @@ public async Task CountAsync(ResourceType resourceType, FilterExpression? f
}
///
- public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken)
+ public async Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken)
where TResource : class, IIdentifiable
{
dynamic repository = GetWriteRepository(typeof(TResource));
- return await repository.GetForCreateAsync(id, cancellationToken);
+ return await repository.GetForCreateAsync(resourceClrType, id, cancellationToken);
}
///
@@ -85,11 +85,11 @@ public async Task UpdateAsync(TResource resourceFromRequest, TResourc
}
///
- public async Task DeleteAsync(TId id, CancellationToken cancellationToken)
+ public async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken)
where TResource : class, IIdentifiable
{
dynamic repository = GetWriteRepository(typeof(TResource));
- await repository.DeleteAsync(id, cancellationToken);
+ await repository.DeleteAsync(resourceFromDatabase, id, cancellationToken);
}
///
@@ -101,11 +101,12 @@ public async Task SetRelationshipAsync(TResource leftResource, object
}
///
- public async Task AddToToManyRelationshipAsync(TId leftId, ISet rightResourceIds, CancellationToken cancellationToken)
+ public async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds,
+ CancellationToken cancellationToken)
where TResource : class, IIdentifiable
{
dynamic repository = GetWriteRepository(typeof(TResource));
- await repository.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken);
+ await repository.AddToToManyRelationshipAsync(leftResource, leftId, rightResourceIds, cancellationToken);
}
///
diff --git a/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs
new file mode 100644
index 0000000000..0d294feb57
--- /dev/null
+++ b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs
@@ -0,0 +1,15 @@
+namespace JsonApiDotNetCore.Resources;
+
+///
+internal sealed class AbstractResourceWrapper : Identifiable, IAbstractResourceWrapper
+{
+ ///
+ public Type AbstractType { get; }
+
+ public AbstractResourceWrapper(Type abstractType)
+ {
+ ArgumentGuard.NotNull(abstractType, nameof(abstractType));
+
+ AbstractType = abstractType;
+ }
+}
diff --git a/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs
new file mode 100644
index 0000000000..7e9ac3c71d
--- /dev/null
+++ b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs
@@ -0,0 +1,12 @@
+namespace JsonApiDotNetCore.Resources;
+
+///
+/// Because an instance cannot be created from an abstract resource type, this wrapper is used to preserve that information.
+///
+internal interface IAbstractResourceWrapper : IIdentifiable
+{
+ ///
+ /// The abstract resource type.
+ ///
+ Type AbstractType { get; }
+}
diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
index 1a61868de5..2315ee7b1a 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs
@@ -133,9 +133,9 @@ public interface IResourceDefinition
///
///
/// Identifies the logical write operation for which this method was called. Possible values: ,
- /// and . Note this intentionally excludes
- /// , and
- /// , because for those endpoints no resource is retrieved upfront.
+ /// , and
+ /// . Note this intentionally excludes and
+ /// , because for those endpoints no resource is retrieved upfront.
///
///
/// Propagates notification that request handling should be canceled.
@@ -203,9 +203,22 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa
/// Implementing this method enables to perform validations and make changes to , before the relationship is updated.
///
///
- ///
+ ///
/// Identifier of the left resource. The indication "left" specifies that is declared on
- /// .
+ /// . In contrast to other relationship methods, this value is not retrieved from the underlying data store, except in
+ /// the following two cases:
+ ///
+ /// -
+ ///
+ /// is a many-to-many relationship. This is required to prevent failure when already assigned.
+ ///
+ ///
+ /// -
+ ///
+ /// The left resource type is part of a type hierarchy. This ensures your business logic runs against the actually stored type.
+ ///
+ ///
+ ///
///
///
/// The to-many relationship being added to.
@@ -216,7 +229,7 @@ Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasMa
///
/// Propagates notification that request handling should be canceled.
///
- Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken);
///
diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs
index a6d229609a..edba16d045 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs
@@ -70,9 +70,9 @@ public Task OnSetToManyRelationshipAsync(TResource leftResource, HasM
///
/// Invokes for the specified resource.
///
- public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
- where TResource : class, IIdentifiable;
+ where TResource : class, IIdentifiable;
///
/// Invokes for the specified resource.
diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs
index a59dc8f15f..8a6bed2f91 100644
--- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs
+++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs
@@ -22,7 +22,7 @@ public bool Equals(IIdentifiable? left, IIdentifiable? right)
return true;
}
- if (left is null || right is null || left.GetType() != right.GetType())
+ if (left is null || right is null || left.GetClrType() != right.GetClrType())
{
return false;
}
@@ -38,6 +38,6 @@ public bool Equals(IIdentifiable? left, IIdentifiable? right)
public int GetHashCode(IIdentifiable obj)
{
// LocalId is intentionally omitted here, it is okay for hashes to collide.
- return HashCode.Combine(obj.GetType(), obj.StringId);
+ return HashCode.Combine(obj.GetClrType(), obj.StringId);
}
}
diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
index 7e8064826a..8c9d4b6a36 100644
--- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
+++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs
@@ -11,11 +11,11 @@ public static object GetTypedId(this IIdentifiable identifiable)
{
ArgumentGuard.NotNull(identifiable, nameof(identifiable));
- PropertyInfo? property = identifiable.GetType().GetProperty(IdPropertyName);
+ PropertyInfo? property = identifiable.GetClrType().GetProperty(IdPropertyName);
if (property == null)
{
- throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not contain a property named '{IdPropertyName}'.");
+ throw new InvalidOperationException($"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{IdPropertyName}'.");
}
object? propertyValue = property.GetValue(identifiable);
@@ -27,11 +27,18 @@ public static object GetTypedId(this IIdentifiable identifiable)
if (Equals(propertyValue, defaultValue))
{
- throw new InvalidOperationException($"Property '{identifiable.GetType().Name}.{IdPropertyName}' should " +
+ throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " +
$"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'.");
}
}
return propertyValue!;
}
+
+ public static Type GetClrType(this IIdentifiable identifiable)
+ {
+ ArgumentGuard.NotNull(identifiable, nameof(identifiable));
+
+ return identifiable is IAbstractResourceWrapper abstractResource ? abstractResource.AbstractType : identifiable.GetType();
+ }
}
diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs
index 99f575a293..dbb90bf6fe 100644
--- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs
+++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs
@@ -1,11 +1,14 @@
using System.Collections.Immutable;
using System.ComponentModel;
using System.Linq.Expressions;
+using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Resources.Annotations;
+using JsonApiDotNetCore.Serialization.Objects;
namespace JsonApiDotNetCore.Resources;
@@ -54,8 +57,9 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl
/// model.CreatedAt, ListSortDirection.Ascending),
- /// (model => model.Password, ListSortDirection.Descending)
+ /// (blog => blog.Author.Name.LastName, ListSortDirection.Ascending),
+ /// (blog => blog.Posts.Count, ListSortDirection.Descending),
+ /// (blog => blog.Title, ListSortDirection.Ascending)
/// });
/// ]]>
///
@@ -64,14 +68,26 @@ protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySel
ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors));
ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count);
+ var lambdaConverter = new SortExpressionLambdaConverter(ResourceGraph);
- foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors)
+ foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors)
{
- bool isAscending = sortDirection == ListSortDirection.Ascending;
- AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single();
-
- var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending);
- elementsBuilder.Add(sortElement);
+ try
+ {
+ SortElementExpression sortElement = lambdaConverter.FromLambda(keySelector, sortDirection);
+ elementsBuilder.Add(sortElement);
+ }
+ catch (InvalidOperationException exception)
+ {
+ throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError)
+ {
+ Title = "Invalid lambda expression for sorting from resource definition. " +
+ "It should select a property that is exposed as an attribute, or a to-many relationship followed by Count(). " +
+ "The property can be preceded by a path of to-one relationships. " +
+ "Examples: 'blog => blog.Title', 'blog => blog.Posts.Count', 'blog => blog.Author.Name.LastName'.",
+ Detail = $"The lambda expression '{keySelector}' is invalid. {exception.Message}"
+ }, exception);
+ }
}
return new SortExpression(elementsBuilder.ToImmutable());
@@ -122,7 +138,7 @@ public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasMany
}
///
- public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public virtual Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -161,7 +177,7 @@ public virtual void OnSerialize(TResource resource)
/// This is an alias type intended to simplify the implementation's method signature. See for usage
/// details.
///
- public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)>
+ public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)>
{
}
}
diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
index 7ccd381456..658e5e2c5b 100644
--- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
+++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs
@@ -1,6 +1,6 @@
using System.Text.Json;
using JetBrains.Annotations;
-using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;
namespace JsonApiDotNetCore.Resources;
@@ -10,19 +10,19 @@ namespace JsonApiDotNetCore.Resources;
public sealed class ResourceChangeTracker : IResourceChangeTracker
where TResource : class, IIdentifiable
{
- private readonly ResourceType _resourceType;
+ private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
private IDictionary? _initiallyStoredAttributeValues;
private IDictionary? _requestAttributeValues;
private IDictionary? _finallyStoredAttributeValues;
- public ResourceChangeTracker(IResourceGraph resourceGraph, ITargetedFields targetedFields)
+ public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields)
{
- ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
+ ArgumentGuard.NotNull(request, nameof(request));
ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
- _resourceType = resourceGraph.GetResourceType();
+ _request = request;
_targetedFields = targetedFields;
}
@@ -31,7 +31,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource)
{
ArgumentGuard.NotNull(resource, nameof(resource));
- _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes);
+ _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes);
}
///
@@ -47,7 +47,7 @@ public void SetFinallyStoredAttributeValues(TResource resource)
{
ArgumentGuard.NotNull(resource, nameof(resource));
- _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _resourceType.Attributes);
+ _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes);
}
private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes)
diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs
index a16b04cfea..9f8dfdddeb 100644
--- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs
+++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs
@@ -104,8 +104,8 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat
{
ArgumentGuard.NotNull(resource, nameof(resource));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType());
+ await resourceDefinition.OnPrepareWriteAsync((dynamic)resource, writeOperation, cancellationToken);
}
///
@@ -116,8 +116,10 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat
ArgumentGuard.NotNull(leftResource, nameof(leftResource));
ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, writeOperation, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType());
+
+ return await resourceDefinition.OnSetToOneRelationshipAsync((dynamic)leftResource, hasOneRelationship, rightResourceId, writeOperation,
+ cancellationToken);
}
///
@@ -129,20 +131,20 @@ public async Task OnSetToManyRelationshipAsync(TResource leftResource
ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship));
ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType());
+ await resourceDefinition.OnSetToManyRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken);
}
///
- public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public async Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
- where TResource : class, IIdentifiable
+ where TResource : class, IIdentifiable
{
ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship));
ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType());
+ await resourceDefinition.OnAddToRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken);
}
///
@@ -154,8 +156,8 @@ public async Task OnRemoveFromRelationshipAsync(TResource leftResourc
ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship));
ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType());
+ await resourceDefinition.OnRemoveFromRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken);
}
///
@@ -164,8 +166,8 @@ public async Task OnWritingAsync(TResource resource, WriteOperationKi
{
ArgumentGuard.NotNull(resource, nameof(resource));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnWritingAsync(resource, writeOperation, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType());
+ await resourceDefinition.OnWritingAsync((dynamic)resource, writeOperation, cancellationToken);
}
///
@@ -174,8 +176,8 @@ public async Task OnWriteSucceededAsync(TResource resource, WriteOper
{
ArgumentGuard.NotNull(resource, nameof(resource));
- dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource));
- await resourceDefinition.OnWriteSucceededAsync(resource, writeOperation, cancellationToken);
+ dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType());
+ await resourceDefinition.OnWriteSucceededAsync((dynamic)resource, writeOperation, cancellationToken);
}
///
@@ -183,7 +185,7 @@ public void OnDeserialize(IIdentifiable resource)
{
ArgumentGuard.NotNull(resource, nameof(resource));
- dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType());
+ dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType());
resourceDefinition.OnDeserialize((dynamic)resource);
}
@@ -192,7 +194,7 @@ public void OnSerialize(IIdentifiable resource)
{
ArgumentGuard.NotNull(resource, nameof(resource));
- dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType());
+ dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType());
resourceDefinition.OnSerialize((dynamic)resource);
}
diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs
index fa3e571c7c..c276c073cd 100644
--- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs
+++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs
@@ -1,5 +1,6 @@
using System.Linq.Expressions;
using System.Reflection;
+using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Queries.Internal;
using Microsoft.Extensions.DependencyInjection;
@@ -8,6 +9,8 @@ namespace JsonApiDotNetCore.Resources;
///
internal sealed class ResourceFactory : IResourceFactory
{
+ private static readonly TypeLocator TypeLocator = new();
+
private readonly IServiceProvider _serviceProvider;
public ResourceFactory(IServiceProvider serviceProvider)
@@ -22,9 +25,35 @@ public IIdentifiable CreateInstance(Type resourceClrType)
{
ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType));
+ if (!resourceClrType.IsAssignableTo(typeof(IIdentifiable)))
+ {
+ throw new InvalidOperationException($"Resource type '{resourceClrType}' does not implement IIdentifiable.");
+ }
+
+ if (resourceClrType.IsAbstract)
+ {
+ return CreateWrapperForAbstractType(resourceClrType);
+ }
+
return InnerCreateInstance(resourceClrType, _serviceProvider);
}
+ private static IIdentifiable CreateWrapperForAbstractType(Type resourceClrType)
+ {
+ ResourceDescriptor? descriptor = TypeLocator.ResolveResourceDescriptor(resourceClrType);
+
+ if (descriptor == null)
+ {
+ throw new InvalidOperationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'.");
+ }
+
+ Type wrapperClrType = typeof(AbstractResourceWrapper<>).MakeGenericType(descriptor.IdClrType);
+ ConstructorInfo constructor = wrapperClrType.GetConstructors().Single();
+
+ object resource = constructor.Invoke(ArrayFactory.Create(resourceClrType));
+ return (IIdentifiable)resource;
+ }
+
///
public TResource CreateInstance()
where TResource : IIdentifiable
diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs
new file mode 100644
index 0000000000..3736fff6d9
--- /dev/null
+++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs
@@ -0,0 +1,167 @@
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.Linq.Expressions;
+using System.Reflection;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Queries.Expressions;
+using JsonApiDotNetCore.Resources.Annotations;
+
+namespace JsonApiDotNetCore.Resources;
+
+internal sealed class SortExpressionLambdaConverter
+{
+ private readonly IResourceGraph _resourceGraph;
+ private readonly IList _fields = new List();
+
+ public SortExpressionLambdaConverter(IResourceGraph resourceGraph)
+ {
+ ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
+
+ _resourceGraph = resourceGraph;
+ }
+
+ public SortElementExpression FromLambda(Expression> keySelector, ListSortDirection sortDirection)
+ {
+ ArgumentGuard.NotNull(keySelector, nameof(keySelector));
+
+ _fields.Clear();
+
+ Expression lambdaBodyExpression = SkipConvert(keySelector.Body);
+ (Expression? expression, bool isCount) = TryReadCount(lambdaBodyExpression);
+
+ if (expression != null)
+ {
+ expression = SkipConvert(expression);
+ expression = isCount ? ReadToManyRelationship(expression) : ReadAttribute(expression);
+
+ while (expression != null)
+ {
+ expression = SkipConvert(expression);
+
+ if (IsLambdaParameter(expression, keySelector.Parameters[0]))
+ {
+ return ToSortElement(isCount, sortDirection);
+ }
+
+ expression = ReadToOneRelationship(expression);
+ }
+ }
+
+ throw new InvalidOperationException($"Unsupported expression body '{lambdaBodyExpression}'.");
+ }
+
+ private static Expression SkipConvert(Expression expression)
+ {
+ Expression inner = expression;
+
+ while (true)
+ {
+ if (inner is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.TypeAs } unary)
+ {
+ inner = unary.Operand;
+ }
+ else
+ {
+ return inner;
+ }
+ }
+ }
+
+ private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression)
+ {
+ if (expression is MethodCallExpression methodCallExpression && methodCallExpression.Method.Name == "Count")
+ {
+ if (methodCallExpression.Arguments.Count <= 1)
+ {
+ return (methodCallExpression.Arguments[0], true);
+ }
+
+ throw new InvalidOperationException("Count method that takes a predicate is not supported.");
+ }
+
+ if (expression is MemberExpression memberExpression)
+ {
+ if (memberExpression.Member.MemberType == MemberTypes.Property && memberExpression.Member.Name is "Count" or "Length")
+ {
+ if (memberExpression.Member.GetCustomAttribute() == null)
+ {
+ return (memberExpression.Expression, true);
+ }
+ }
+
+ return (memberExpression, false);
+ }
+
+ return (null, false);
+ }
+
+ private Expression? ReadToManyRelationship(Expression expression)
+ {
+ if (expression is MemberExpression memberExpression)
+ {
+ ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!);
+ RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name);
+
+ if (relationship is HasManyAttribute)
+ {
+ _fields.Insert(0, relationship);
+ return memberExpression.Expression;
+ }
+ }
+
+ throw new InvalidOperationException($"Expected property for JSON:API to-many relationship, but found '{expression}'.");
+ }
+
+ private Expression? ReadAttribute(Expression expression)
+ {
+ if (expression is MemberExpression memberExpression)
+ {
+ ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!);
+ AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name);
+
+ if (attribute != null)
+ {
+ _fields.Insert(0, attribute);
+ return memberExpression.Expression;
+ }
+ }
+
+ throw new InvalidOperationException($"Expected property for JSON:API attribute, but found '{expression}'.");
+ }
+
+ private Expression? ReadToOneRelationship(Expression expression)
+ {
+ if (expression is MemberExpression memberExpression)
+ {
+ ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!);
+ RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name);
+
+ if (relationship is HasOneAttribute)
+ {
+ _fields.Insert(0, relationship);
+ return memberExpression.Expression;
+ }
+ }
+
+ throw new InvalidOperationException($"Expected property for JSON:API to-one relationship, but found '{expression}'.");
+ }
+
+ private static bool IsLambdaParameter(Expression expression, ParameterExpression lambdaParameter)
+ {
+ return expression is ParameterExpression parameterExpression && parameterExpression.Name == lambdaParameter.Name;
+ }
+
+ private SortElementExpression ToSortElement(bool isCount, ListSortDirection sortDirection)
+ {
+ var chain = new ResourceFieldChainExpression(_fields.ToImmutableArray());
+ bool isAscending = sortDirection == ListSortDirection.Ascending;
+
+ if (isCount)
+ {
+ var countExpression = new CountExpression(chain);
+ return new SortElementExpression(countExpression, isAscending);
+ }
+
+ return new SortElementExpression(chain, isAscending);
+ }
+}
diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
index f453b8dd21..d163eb56d1 100644
--- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs
@@ -46,6 +46,12 @@ private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityReq
ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type);
AssertIsKnownResourceType(resourceType, identity.Type, state);
+
+ if (state.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource)
+ {
+ AssertIsNotAbstractType(resourceType, identity.Type, state);
+ }
+
AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state);
return resourceType;
@@ -67,13 +73,21 @@ private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceTy
}
}
+ private static void AssertIsNotAbstractType(ResourceType resourceType, string typeName, RequestAdapterState state)
+ {
+ if (resourceType.ClrType.IsAbstract)
+ {
+ throw new ModelConversionException(state.Position, "Abstract resource type found.", $"Resource type '{typeName}' is abstract.");
+ }
+ }
+
private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state)
{
if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType))
{
string message = relationshipName != null
- ? $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}' of relationship '{relationshipName}'."
- : $"Type '{actual.PublicName}' is incompatible with type '{expected.PublicName}'.";
+ ? $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}' of relationship '{relationshipName}'."
+ : $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}'.";
throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict);
}
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
index df75fcaa66..f18eeb8913 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs
@@ -251,19 +251,19 @@ private ResourceObjectComparer()
{
}
- public bool Equals(ResourceObject? x, ResourceObject? y)
+ public bool Equals(ResourceObject? left, ResourceObject? right)
{
- if (ReferenceEquals(x, y))
+ if (ReferenceEquals(left, right))
{
return true;
}
- if (x is null || y is null || x.GetType() != y.GetType())
+ if (left is null || right is null || left.GetType() != right.GetType())
{
return false;
}
- return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid;
+ return left.Type == right.Type && left.Id == right.Id && left.Lid == right.Lid;
}
public int GetHashCode(ResourceObject obj)
diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
index d881668c9f..af1a78bee1 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs
@@ -3,6 +3,7 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Queries.Internal;
@@ -172,8 +173,11 @@ private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, Resou
{
if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode))
{
- ResourceObject resourceObject = ConvertResource(resource, resourceType, kind);
- treeNode = new ResourceObjectTreeNode(resource, resourceType, resourceObject);
+ // In case of resource inheritance, prefer the derived resource type over the base type.
+ ResourceType effectiveResourceType = GetEffectiveResourceType(resource, resourceType);
+
+ ResourceObject resourceObject = ConvertResource(resource, effectiveResourceType, kind);
+ treeNode = new ResourceObjectTreeNode(resource, effectiveResourceType, resourceObject);
_resourceToTreeNodeCache.Add(resource, treeNode);
}
@@ -181,6 +185,25 @@ private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, Resou
return treeNode;
}
+ private static ResourceType GetEffectiveResourceType(IIdentifiable resource, ResourceType declaredType)
+ {
+ Type runtimeResourceType = resource.GetClrType();
+
+ if (declaredType.ClrType == runtimeResourceType)
+ {
+ return declaredType;
+ }
+
+ ResourceType? derivedType = declaredType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == runtimeResourceType);
+
+ if (derivedType == null)
+ {
+ throw new InvalidConfigurationException($"Type '{runtimeResourceType}' does not exist in the resource graph.");
+ }
+
+ return derivedType;
+ }
+
protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind)
{
bool isRelationship = kind == EndpointKind.Relationship;
@@ -251,14 +274,25 @@ private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTre
private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode,
IncludeElementExpression includeElement, EndpointKind kind)
{
- object? rightValue = relationship.GetValue(leftResource);
+ if (!relationship.LeftType.ClrType.IsAssignableFrom(leftTreeNode.ResourceType.ClrType))
+ {
+ // Skipping over relationship that is declared on another derived type.
+ return;
+ }
+
+ // In case of resource inheritance, prefer the relationship on derived type over the one on base type.
+ RelationshipAttribute effectiveRelationship = !leftTreeNode.ResourceType.Equals(relationship.LeftType)
+ ? leftTreeNode.ResourceType.GetRelationshipByPropertyName(relationship.Property.Name)
+ : relationship;
+
+ object? rightValue = effectiveRelationship.GetValue(leftResource);
ICollection rightResources = CollectionConverter.ExtractResources(rightValue);
- leftTreeNode.EnsureHasRelationship(relationship);
+ leftTreeNode.EnsureHasRelationship(effectiveRelationship);
foreach (IIdentifiable rightResource in rightResources)
{
- TraverseResource(rightResource, relationship.RightType, kind, includeElement.Children, leftTreeNode, relationship);
+ TraverseResource(rightResource, effectiveRelationship.RightType, kind, includeElement.Children, leftTreeNode, effectiveRelationship);
}
}
diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs
index 87046783c5..540ee5b73b 100644
--- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs
+++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs
@@ -196,14 +196,17 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa
});
ArgumentGuard.NotNull(resource, nameof(resource));
- AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType);
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource");
TResource resourceFromRequest = resource;
_resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest);
- TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken);
+ await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken);
+
+ Type resourceClrType = resourceFromRequest.GetClrType();
+ TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resourceClrType, resourceFromRequest.Id, cancellationToken);
+ AccurizeJsonApiRequest(resourceForDatabase);
_resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase);
@@ -246,19 +249,44 @@ protected virtual async Task InitializeResourceAsync(TResource resourceForDataba
await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken);
}
+ private async Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(TResource primaryResource, CancellationToken cancellationToken)
+ {
+ await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, true, cancellationToken);
+ }
+
protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken)
+ {
+ await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, false, cancellationToken);
+ }
+
+ private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(TResource primaryResource, bool onlyIfTypeHierarchy,
+ CancellationToken cancellationToken)
{
var missingResources = new List();
foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(primaryResource))
{
- object? rightValue = relationship.GetValue(primaryResource);
- ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue);
+ if (!onlyIfTypeHierarchy || relationship.RightType.IsPartOfTypeHierarchy())
+ {
+ object? rightValue = relationship.GetValue(primaryResource);
+ HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance);
+
+ if (rightResourceIds.Any())
+ {
+ IAsyncEnumerable missingResourcesInRelationship =
+ GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken);
- IAsyncEnumerable missingResourcesInRelationship =
- GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken);
+ await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken);
- await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken);
+ // Some of the right-side resources from request may be typed as base types, but stored as derived types.
+ // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types.
+ object? newRightValue = relationship is HasOneAttribute
+ ? rightResourceIds.FirstOrDefault()
+ : _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType);
+
+ relationship.SetValue(primaryResource, newRightValue);
+ }
+ }
}
if (missingResources.Any())
@@ -268,20 +296,35 @@ protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource
}
private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer,
- RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken)
+ RelationshipAttribute relationship, ISet rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken)
{
IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType,
existingRightResourceIdsQueryLayer, cancellationToken);
- string[] existingResourceIds = existingResources.Select(resource => resource.StringId!).ToArray();
-
- foreach (IIdentifiable rightResourceId in rightResourceIds)
+ foreach (IIdentifiable rightResourceId in rightResourceIds.ToArray())
{
- if (!existingResourceIds.Contains(rightResourceId.StringId))
+ Type rightResourceClrType = rightResourceId.GetClrType();
+ IIdentifiable? existingResourceId = existingResources.FirstOrDefault(resource => resource.StringId == rightResourceId.StringId);
+
+ if (existingResourceId != null)
{
- yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceType.PublicName,
- rightResourceId.StringId!);
+ Type existingResourceClrType = existingResourceId.GetClrType();
+
+ if (rightResourceClrType.IsAssignableFrom(existingResourceClrType))
+ {
+ if (rightResourceClrType != existingResourceClrType)
+ {
+ // PERF: As a side effect, we replace the resource base type from request with the derived type that is stored.
+ rightResourceIds.Remove(rightResourceId);
+ rightResourceIds.Add(existingResourceId);
+ }
+
+ continue;
+ }
}
+
+ ResourceType requestResourceType = relationship.RightType.GetTypeOrDerived(rightResourceClrType);
+ yield return new MissingResourceInRelationship(relationship.PublicName, requestResourceType.PublicName, rightResourceId.StringId!);
}
}
@@ -292,6 +335,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati
_traceWriter.LogMethodStart(new
{
leftId,
+ relationshipName,
rightResourceIds
});
@@ -302,36 +346,56 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati
AssertHasRelationship(_request.Relationship, relationshipName);
+ TResource? resourceFromDatabase = null;
+
if (rightResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship)
{
// In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a
// unique constraint violation. We avoid that by excluding already-existing entries from the set in advance.
- await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken);
+ resourceFromDatabase = await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken);
+ }
+
+ if (_request.Relationship.LeftType.IsPartOfTypeHierarchy())
+ {
+ // The left resource may be stored as a derived type. We fetch it, so we'll know the stored type, which
+ // enables to invoke IResourceDefinition with TResource being the stored resource type.
+ resourceFromDatabase ??= await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken);
+ AccurizeJsonApiRequest(resourceFromDatabase);
+ }
+
+ ISet effectiveRightResourceIds = rightResourceIds;
+
+ if (_request.Relationship.RightType.IsPartOfTypeHierarchy())
+ {
+ // Some of the incoming right-side resources may be stored as a derived type. We fetch them, so we'll know
+ // the stored types, which enables to invoke resource definitions with the stored right-side resources types.
+ object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken);
+ effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance);
}
try
{
- await _repositoryAccessor.AddToToManyRelationshipAsync(leftId, rightResourceIds, cancellationToken);
+ await _repositoryAccessor.AddToToManyRelationshipAsync(resourceFromDatabase, leftId, effectiveRightResourceIds, cancellationToken);
}
catch (DataStoreUpdateException)
{
await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken);
- await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken);
+ await AssertRightResourcesExistAsync(effectiveRightResourceIds, cancellationToken);
throw;
}
}
- private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds,
- CancellationToken cancellationToken)
+ private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId,
+ ISet rightResourceIds, CancellationToken cancellationToken)
{
- AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship);
-
TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken);
- object? rightValue = _request.Relationship.GetValue(leftResource);
+ object? rightValue = hasManyRelationship.GetValue(leftResource);
ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue);
rightResourceIds.ExceptWith(existingRightResourceIds);
+
+ return leftResource;
}
private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds,
@@ -344,11 +408,12 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR
return leftResource;
}
- protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken)
+ protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken)
{
AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship);
- ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue);
+ HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance);
+ object? newRightValue = rightValue;
if (rightResourceIds.Any())
{
@@ -357,11 +422,19 @@ protected async Task AssertRightResourcesExistAsync(object? rightValue, Cancella
List missingResources =
await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, rightResourceIds, cancellationToken).ToListAsync(cancellationToken);
+ // Some of the right-side resources from request may be typed as base types, but stored as derived types.
+ // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types.
+ newRightValue = _request.Relationship is HasOneAttribute
+ ? rightResourceIds.FirstOrDefault()
+ : _collectionConverter.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType);
+
if (missingResources.Any())
{
throw new ResourcesInRelationshipsNotFoundException(missingResources);
}
}
+
+ return newRightValue;
}
///
@@ -380,7 +453,10 @@ protected async Task AssertRightResourcesExistAsync(object? rightValue, Cancella
TResource resourceFromRequest = resource;
_resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest);
+ await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken);
+
TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken);
+ AccurizeJsonApiRequest(resourceFromDatabase);
_resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase);
@@ -420,17 +496,24 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa
AssertHasRelationship(_request.Relationship, relationshipName);
+ object? effectiveRightValue = _request.Relationship.RightType.IsPartOfTypeHierarchy()
+ // Some of the incoming right-side resources may be stored as a derived type. We fetch them, so we'll know
+ // the stored types, which enables to invoke resource definitions with the stored right-side resources types.
+ ? await AssertRightResourcesExistAsync(rightValue, cancellationToken)
+ : rightValue;
+
TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken);
+ AccurizeJsonApiRequest(resourceFromDatabase);
await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken);
try
{
- await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken);
+ await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, effectiveRightValue, cancellationToken);
}
catch (DataStoreUpdateException)
{
- await AssertRightResourcesExistAsync(rightValue, cancellationToken);
+ await AssertRightResourcesExistAsync(effectiveRightValue, cancellationToken);
throw;
}
}
@@ -445,9 +528,21 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource");
+ AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType);
+
+ TResource? resourceFromDatabase = null;
+
+ if (_request.PrimaryResourceType.IsPartOfTypeHierarchy())
+ {
+ // The resource to delete may be stored as a derived type. We fetch it, so we'll know the stored type, which
+ // enables to invoke IResourceDefinition with TResource being the stored resource type.
+ resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken);
+ AccurizeJsonApiRequest(resourceFromDatabase);
+ }
+
try
{
- await _repositoryAccessor.DeleteAsync(id, cancellationToken);
+ await _repositoryAccessor.DeleteAsync(resourceFromDatabase, id, cancellationToken);
}
catch (DataStoreUpdateException)
{
@@ -476,10 +571,14 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r
var hasManyRelationship = (HasManyAttribute)_request.Relationship;
TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken);
+ AccurizeJsonApiRequest(resourceFromDatabase);
+
+ object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken);
+ ISet effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance);
- await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken);
+ await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken);
- await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, rightResourceIds, cancellationToken);
+ await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken);
}
protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken)
@@ -511,6 +610,33 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell
return resource;
}
+ private void AccurizeJsonApiRequest(TResource resourceFromDatabase)
+ {
+ // When using resource inheritance, the stored left-side resource may be more derived than what this endpoint assumes.
+ // In that case, we promote data in IJsonApiRequest to better represent what is going on.
+
+ Type storedType = resourceFromDatabase.GetClrType();
+
+ if (storedType != typeof(TResource))
+ {
+ AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType);
+ ResourceType? derivedType = _request.PrimaryResourceType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == storedType);
+
+ if (derivedType == null)
+ {
+ throw new InvalidConfigurationException($"Type '{storedType}' does not exist in the resource graph.");
+ }
+
+ var request = (JsonApiRequest)_request;
+ request.PrimaryResourceType = derivedType;
+
+ if (request.Relationship != null)
+ {
+ request.Relationship = derivedType.GetRelationshipByPublicName(request.Relationship.PublicName);
+ }
+ }
+ }
+
[AssertionMethod]
private void AssertPrimaryResourceExists([SysNotNull] TResource? resource)
{
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
index 465f39cee1..32024c03dd 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs
@@ -464,7 +464,7 @@ public async Task Cannot_create_on_relationship_type_mismatch()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
index 64fc6e66d3..a0ed043ae8 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs
@@ -648,7 +648,7 @@ public async Task Cannot_create_on_relationship_type_mismatch()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs
index c50c52e08e..2302ac20f7 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs
@@ -19,7 +19,7 @@ public Task CountAsync(FilterExpression? filter, CancellationToken cancella
throw new NotImplementedException();
}
- public Task GetForCreateAsync(int id, CancellationToken cancellationToken)
+ public Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
@@ -39,7 +39,7 @@ public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDat
throw new NotImplementedException();
}
- public Task DeleteAsync(int id, CancellationToken cancellationToken)
+ public Task DeleteAsync(Performer? resourceFromDatabase, int id, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
@@ -49,7 +49,7 @@ public Task SetRelationshipAsync(Performer leftResource, object? rightValue, Can
throw new NotImplementedException();
}
- public Task AddToToManyRelationshipAsync(int leftId, ISet rightResourceIds, CancellationToken cancellationToken)
+ public Task AddToToManyRelationshipAsync(Performer? leftResource, int leftId, ISet rightResourceIds, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
index bd97c23411..d249ec2563 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs
@@ -1026,7 +1026,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
index 1e6787a0d7..e15e926293 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs
@@ -986,7 +986,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
index ee3ea791a5..abb9b423d1 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs
@@ -1135,7 +1135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
index b66a5dfc26..13bc5a107f 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs
@@ -1300,7 +1300,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
index 98b8892800..8ea9ac6574 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs
@@ -794,7 +794,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers' of relationship 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
index f8051e369c..1679ff3fa3 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs
@@ -1149,7 +1149,7 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data()
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'performers'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
index efa3813d74..b2a7f04262 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs
@@ -1041,7 +1041,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.Conflict);
error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found.");
- error.Detail.Should().Be("Type 'playlists' is incompatible with type 'lyrics' of relationship 'lyric'.");
+ error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type");
error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty());
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs
index 4009f1dd82..989967bc10 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs
@@ -41,9 +41,11 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer)
queryLayer.Sort = (SortExpression?)_writer.Visit(queryLayer.Sort, null);
}
- if (queryLayer.Projection != null)
+ if (queryLayer.Selection is { IsEmpty: false })
{
- foreach (QueryLayer? nextLayer in queryLayer.Projection.Values.Where(layer => layer != null))
+ foreach (QueryLayer? nextLayer in queryLayer.Selection.GetResourceTypes()
+ .Select(resourceType => queryLayer.Selection.GetOrCreateSelectors(resourceType))
+ .SelectMany(selectors => selectors.Select(selector => selector.Value).Where(layer => layer != null)))
{
RecursiveRewriteFilterInLayer(nextLayer!);
}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs
index 572a7bd3e6..888f060ca1 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingRepository.cs
@@ -17,9 +17,9 @@ public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbC
{
}
- public override async Task GetForCreateAsync(int id, CancellationToken cancellationToken)
+ public override async Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken)
{
- Building building = await base.GetForCreateAsync(id, cancellationToken);
+ Building building = await base.GetForCreateAsync(resourceClrType, id, cancellationToken);
// Must ensure that an instance exists for this required relationship, so that POST Resource succeeds.
building.PrimaryDoor = new Door
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs
index 57098822a1..8132728c90 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs
@@ -129,7 +129,7 @@ public override Task OnSetToManyRelationshipAsync(TResource leftResource, HasMan
return base.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken);
}
- public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public override Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
{
if (ExtensibilityPointsToTrack.HasFlag(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync))
@@ -137,7 +137,7 @@ public override Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribu
_hitCounter.TrackInvocation(ResourceDefinitionExtensibilityPoints.OnAddToRelationshipAsync);
}
- return base.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken);
+ return base.OnAddToRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken);
}
public override Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs
index e35efaeb52..5b4acbc311 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs
@@ -569,6 +569,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
+ (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs
index ce8e10c62e..9365ff08a0 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs
@@ -80,10 +80,10 @@ public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasMa
}
}
- public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
+ public override async Task OnAddToRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds,
CancellationToken cancellationToken)
{
- await base.OnAddToRelationshipAsync(groupId, hasManyRelationship, rightResourceIds, cancellationToken);
+ await base.OnAddToRelationshipAsync(group, hasManyRelationship, rightResourceIds, cancellationToken);
if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users))
{
@@ -98,11 +98,11 @@ public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribu
if (beforeUser.Group == null)
{
- content = new UserAddedToGroupContent(beforeUser.Id, groupId);
+ content = new UserAddedToGroupContent(beforeUser.Id, group.Id);
}
- else if (beforeUser.Group != null && beforeUser.Group.Id != groupId)
+ else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id)
{
- content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, groupId);
+ content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id);
}
if (content != null)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs
index a5c61d1f70..16c4394ed8 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs
@@ -606,6 +606,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[]
{
+ (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnPrepareWriteAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnRemoveFromRelationshipAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWritingAsync),
(typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync)
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
index a5edb3123f..a628cf9355 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs
@@ -20,6 +20,9 @@ public sealed class BlogPost : Identifiable
[HasOne]
public WebAccount? Reviewer { get; set; }
+ [HasMany]
+ public ISet Contributors { get; set; } = new HashSet();
+
[HasMany]
public ISet