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 { private readonly Stack _parentRelationshipStack = new(); @@ -144,22 +90,4 @@ private void FlushChain(IncludeElementExpression expression) Chains.Add(new ResourceFieldChainExpression(chainBuilder.ToImmutable())); } } - - private sealed class MutableIncludeNode - { - private readonly RelationshipAttribute _relationship; - - public IDictionary Children { get; } = new Dictionary(); - - public MutableIncludeNode(RelationshipAttribute relationship) - { - _relationship = relationship; - } - - public IncludeElementExpression ToExpression() - { - IImmutableSet elementChildren = Children.Values.Select(child => child.ToExpression()).ToImmutableHashSet(); - return new IncludeElementExpression(_relationship, elementChildren); - } - } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index cd95ef61a3..e76aaf0946 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -34,14 +34,24 @@ public override TResult Accept(QueryExpressionVisitor child.ToString()).OrderBy(name => name))); + builder.Append(string.Join(",", Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).OrderBy(name => name))); builder.Append('}'); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 4597570ba3..a63d87719d 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -33,9 +33,19 @@ public override TResult Accept(QueryExpressionVisitor chains = IncludeChainConverter.GetRelationshipChains(this); - return string.Join(",", chains.Select(child => child.ToString()).OrderBy(name => name)); + return string.Join(",", chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).OrderBy(name => name)); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs new file mode 100644 index 0000000000..a30e31308b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -0,0 +1,88 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Internal.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the "isType" filter function, resulting from text such as: isType(,men), isType(creator,men) or +/// isType(creator,men,equals(hasBeard,'true')) +/// +[PublicAPI] +public class IsTypeExpression : FilterExpression +{ + public ResourceFieldChainExpression? TargetToOneRelationship { get; } + public ResourceType DerivedType { get; } + public FilterExpression? Child { get; } + + public IsTypeExpression(ResourceFieldChainExpression? targetToOneRelationship, ResourceType derivedType, FilterExpression? child) + { + ArgumentGuard.NotNull(derivedType, nameof(derivedType)); + + TargetToOneRelationship = targetToOneRelationship; + DerivedType = derivedType; + Child = child; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIsType(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + builder.Append(Keywords.IsType); + builder.Append('('); + + if (TargetToOneRelationship != null) + { + builder.Append(toFullString ? TargetToOneRelationship.ToFullString() : TargetToOneRelationship); + } + + builder.Append(','); + builder.Append(DerivedType); + + if (Child != null) + { + builder.Append(','); + builder.Append(toFullString ? Child.ToFullString() : Child); + } + + builder.Append(')'); + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (IsTypeExpression)obj; + + return Equals(TargetToOneRelationship, other.TargetToOneRelationship) && DerivedType.Equals(other.DerivedType) && Equals(Child, other.Child); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToOneRelationship, DerivedType, Child); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index bc5b4790ac..17c62f230f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -28,6 +28,11 @@ public override string ToString() return $"'{value}'"; } + public override string ToFullString() + { + return ToString(); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index 0308c04de2..c8d8ffb24b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -47,12 +47,22 @@ public override TResult Accept(QueryExpressionVisitor term.ToString()))); + builder.Append(string.Join(",", Terms.Select(term => toFullString ? term.ToFullString() : term.ToString()))); builder.Append(')'); return builder.ToString(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index f528790fd3..a9c598402b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -30,12 +30,26 @@ public override TResult Accept(QueryExpressionVisitor(QueryExpressionVisitor constant.ToString())); + return string.Join(",", Elements.Select(element => element.ToString())); + } + + public override string ToFullString() + { + return string.Join(",", Elements.Select(element => element.ToFullString())); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index e442e6968d..2ff93dafe4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -9,4 +9,6 @@ namespace JsonApiDotNetCore.Queries.Expressions; public abstract class QueryExpression { public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); + + public abstract string ToFullString(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index cd5937cd80..7051e81f73 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -91,6 +91,18 @@ public override QueryExpression VisitNullConstant(NullConstantExpression express return null; } + public override QueryExpression VisitIsType(IsTypeExpression expression, TArgument argument) + { + ResourceFieldChainExpression? newTargetToOneRelationship = expression.TargetToOneRelationship != null + ? Visit(expression.TargetToOneRelationship, argument) as ResourceFieldChainExpression + : null; + + FilterExpression? newChild = expression.Child != null ? Visit(expression.Child, argument) as FilterExpression : null; + + var newExpression = new IsTypeExpression(newTargetToOneRelationship, expression.DerivedType, newChild); + return newExpression.Equals(expression) ? expression : newExpression; + } + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { SortElementExpression? newExpression = null; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index 7c893ba81c..7dcf44b1f4 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -53,6 +53,11 @@ public virtual TResult VisitHas(HasExpression expression, TArgument argument) return DefaultVisit(expression, argument); } + public virtual TResult VisitIsType(IsTypeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitSortElement(SortElementExpression expression, TArgument argument) { return DefaultVisit(expression, argument); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index db0e887c09..e567da8778 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -29,6 +29,11 @@ public override string ToString() return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; } + public override string ToFullString() + { + return Scope == null ? ParameterName.ToFullString() : $"{ParameterName.ToFullString()}: {Scope.ToFullString()}"; + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index bffd8ae0ce..1d9c910955 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -38,6 +38,11 @@ public override string ToString() return $"handler('{_parameterValue}')"; } + public override string ToFullString() + { + return ToString(); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 7f19c55ba0..7decec6221 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -36,6 +36,11 @@ public override string ToString() return string.Join(".", Fields.Select(field => field.PublicName)); } + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}")); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index 9de73655ad..78de440a42 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -35,6 +35,16 @@ public override TResult Accept(QueryExpressionVisitor child.ToString())); } + public override string ToFullString() + { + return string.Join(",", Elements.Select(child => child.ToFullString())); + } + public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index c0532070e5..bc1e611bd8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -26,7 +26,12 @@ public override TResult Accept(QueryExpressionVisitor child.PublicName).OrderBy(name => name)); + return string.Join(",", Fields.Select(field => field.PublicName).OrderBy(name => name)); + } + + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}").OrderBy(name => name)); } public override bool Equals(object? obj) diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index f0e434cccd..53f9ff0eb6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; public static class SparseFieldSetExpressionExtensions { public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); @@ -39,7 +39,7 @@ public static class SparseFieldSetExpressionExtensions } public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 8ec77f12fc..8e52df9b3b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -26,10 +26,20 @@ public override TResult Accept(QueryExpressionVisitor 0) { @@ -38,7 +48,7 @@ public override string ToString() builder.Append(resourceType.PublicName); builder.Append('('); - builder.Append(fields); + builder.Append(toFullString ? fieldSet.ToFullString() : fieldSet); builder.Append(')'); } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs new file mode 100644 index 0000000000..8570addf51 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -0,0 +1,75 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// Provides access to sparse fieldsets, per resource type. There's usually just a single resource type, but there can be multiple in case an endpoint +/// for an abstract resource type returns derived types. +/// +[PublicAPI] +public sealed class FieldSelection : Dictionary +{ + public bool IsEmpty => Values.All(selectors => selectors.IsEmpty); + + public ISet GetResourceTypes() + { + return Keys.ToHashSet(); + } + +#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type + public FieldSelectors GetOrCreateSelectors(ResourceType resourceType) +#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + + if (!ContainsKey(resourceType)) + { + this[resourceType] = new FieldSelectors(); + } + + return this[resourceType]; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + var writer = new IndentingStringWriter(builder); + WriteSelection(writer); + + return builder.ToString(); + } + + internal void WriteSelection(IndentingStringWriter writer) + { + using (writer.Indent()) + { + foreach (ResourceType type in GetResourceTypes()) + { + writer.WriteLine($"{nameof(FieldSelectors)}<{type.ClrType.Name}>"); + WriterSelectors(writer, type); + } + } + } + + private void WriterSelectors(IndentingStringWriter writer, ResourceType type) + { + using (writer.Indent()) + { + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in GetOrCreateSelectors(type)) + { + if (nextLayer == null) + { + writer.WriteLine(field.ToString()); + } + else + { + nextLayer.WriteLayer(writer, $"{field.PublicName}: "); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs new file mode 100644 index 0000000000..a07b4f0c79 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -0,0 +1,70 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// A data structure that contains which fields (attributes and relationships) to retrieve, or empty to retrieve all. In the case of a relationship, it +/// contains the nested query constraints. +/// +[PublicAPI] +public sealed class FieldSelectors : Dictionary +{ + public bool IsEmpty => !this.Any(); + + public bool ContainsReadOnlyAttribute + { + get + { + return this.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + } + } + + public bool ContainsOnlyRelationships + { + get + { + return this.All(selector => selector.Key is RelationshipAttribute); + } + } + + public bool ContainsField(ResourceFieldAttribute field) + { + ArgumentGuard.NotNull(field, nameof(field)); + + return ContainsKey(field); + } + + public void IncludeAttribute(AttrAttribute attribute) + { + ArgumentGuard.NotNull(attribute, nameof(attribute)); + + this[attribute] = null; + } + + public void IncludeAttributes(IEnumerable attributes) + { + ArgumentGuard.NotNull(attributes, nameof(attributes)); + + foreach (AttrAttribute attribute in attributes) + { + this[attribute] = null; + } + } + + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer? queryLayer) + { + ArgumentGuard.NotNull(relationship, nameof(relationship)); + + this[relationship] = queryLayer; + } + + public void RemoveAttributes() + { + while (this.Any(pair => pair.Key is AttrAttribute)) + { + ResourceFieldAttribute field = this.First(pair => pair.Key is AttrAttribute).Key; + Remove(field); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs new file mode 100644 index 0000000000..2d5a366c28 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs @@ -0,0 +1,41 @@ +using System.Text; + +namespace JsonApiDotNetCore.Queries; + +internal 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("}"); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs new file mode 100644 index 0000000000..4b779d1ccd --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs @@ -0,0 +1,17 @@ +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +/// +/// Indicates how to handle derived types when resolving resource field chains. +/// +internal enum FieldChainInheritanceRequirement +{ + /// + /// Do not consider derived types when resolving attributes or relationships. + /// + Disabled, + + /// + /// Consider derived types when resolving attributes or relationships, but fail when multiple matches are found. + /// + RequireSingleMatch +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index c2b66f9063..705f057bc5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -28,15 +28,16 @@ public FilterExpression Parse(string source, ResourceType resourceTypeInScope) { ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); + return InScopeOfResourceType(resourceTypeInScope, () => + { + Tokenize(source); - FilterExpression expression = ParseFilter(); + FilterExpression expression = ParseFilter(); - AssertTokenStackIsEmpty(); + AssertTokenStackIsEmpty(); - return expression; + return expression; + }); } protected FilterExpression ParseFilter() @@ -76,6 +77,10 @@ protected FilterExpression ParseFilter() { return ParseHas(); } + case Keywords.IsType: + { + return ParseIsType(); + } } } @@ -259,13 +264,92 @@ protected HasExpression ParseHas() private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) { - ResourceType outerScopeBackup = _resourceTypeInScope!; + return InScopeOfResourceType(hasManyRelationship.RightType, ParseFilter); + } + + private IsTypeExpression ParseIsType() + { + EatText(Keywords.IsType); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + + EatSingleCharacterToken(TokenKind.Comma); + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : _resourceTypeInScope!; + ResourceType derivedType = ParseDerivedType(baseType); + + FilterExpression? child = TryParseFilterInIsType(derivedType); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsTypeExpression(targetToOneRelationship, derivedType, child); + } + + private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + return null; + } + + return ParseFieldChain(FieldChainRequirements.EndsInToOne, "Relationship name or , expected."); + } + + private ResourceType ParseDerivedType(ResourceType baseType) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + string derivedTypeName = token.Value!; + return ResolveDerivedType(baseType, derivedTypeName); + } + + throw new QueryParseException("Resource type expected."); + } + + private ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName) + { + ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); + + if (derivedType == null) + { + throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'."); + } + + return derivedType; + } + + private ResourceType? GetDerivedType(ResourceType baseType, string publicName) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + if (derivedType.PublicName == publicName) + { + return derivedType; + } + + ResourceType? nextType = GetDerivedType(derivedType, publicName); + + if (nextType != null) + { + return nextType; + } + } + + return null; + } + + private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) + { + FilterExpression? filter = null; - _resourceTypeInScope = hasManyRelationship.RightType; + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); - FilterExpression filter = ParseFilter(); + filter = InScopeOfResourceType(derivedType, ParseFilter); + } - _resourceTypeInScope = outerScopeBackup; return filter; } @@ -341,12 +425,19 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, + _validateSingleFieldCallback); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, + _validateSingleFieldCallback); + } + + if (chainRequirements == FieldChainRequirements.EndsInToOne) + { + return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); } if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) @@ -356,4 +447,19 @@ protected override IImmutableList OnResolveFieldChain(st throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); } + + private TResult InScopeOfResourceType(ResourceType resourceType, Func action) + { + ResourceType? backupType = _resourceTypeInScope; + + try + { + _resourceTypeInScope = resourceType; + return action(); + } + finally + { + _resourceTypeInScope = backupType; + } + } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs index 95e51dca92..a453921989 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; +using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -9,67 +11,266 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; [PublicAPI] public class IncludeParser : QueryExpressionParser { - private static readonly IncludeChainConverter IncludeChainConverter = new(); - - private readonly Action? _validateSingleRelationshipCallback; - private ResourceType? _resourceTypeInScope; - - public IncludeParser(Action? validateSingleRelationshipCallback = null) - { - _validateSingleRelationshipCallback = validateSingleRelationshipCallback; - } + private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) { ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - _resourceTypeInScope = resourceTypeInScope; - Tokenize(source); - IncludeExpression expression = ParseInclude(maximumDepth); + IncludeExpression expression = ParseInclude(resourceTypeInScope, maximumDepth); AssertTokenStackIsEmpty(); + ValidateMaximumIncludeDepth(maximumDepth, expression); return expression; } - protected IncludeExpression ParseInclude(int? maximumDepth) + protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth) { - ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); + var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope); - List chains = firstChain.AsList(); + ParseRelationshipChain(treeRoot); while (TokenStack.Any()) { EatSingleCharacterToken(TokenKind.Comma); - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - chains.Add(nextChain); + ParseRelationshipChain(treeRoot); } - ValidateMaximumIncludeDepth(maximumDepth, chains); + return treeRoot.ToExpression(); + } + + private void ParseRelationshipChain(IncludeTreeNode treeRoot) + { + // A relationship name usually matches a single relationship, even when overridden in derived types. + // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet
Items { get; get; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet Items { get; get; } + // } + // + // Now if the include chain has subsequent relationships, we need to scan both Items relationships for matches, + // which is why ParseRelationshipName returns a collection. + // + // The advantage of this unfolding is we don't require callers to upcast in relationship chains. The downside is + // that there's currently no way to include Products without Articles. We could add such optional upcast syntax + // in the future, if desired. + + ICollection children = ParseRelationshipName(treeRoot.AsList()); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); - return IncludeChainConverter.FromRelationshipChains(chains); + children = ParseRelationshipName(children); + } } - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IEnumerable chains) + private ICollection ParseRelationshipName(ICollection parents) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + return LookupRelationshipName(token.Value!, parents); + } + + throw new QueryParseException("Relationship name expected."); + } + + private ICollection LookupRelationshipName(string relationshipName, ICollection parents) + { + List children = new(); + HashSet relationshipsFound = new(); + + foreach (IncludeTreeNode parent in parents) + { + // Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy. + // This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones. + IReadOnlySet relationships = parent.Relationship.RightType.GetRelationshipsInTypeOrDerived(relationshipName); + + if (relationships.Any()) + { + relationshipsFound.AddRange(relationships); + + RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => relationship.CanInclude).ToArray(); + ICollection affectedChildren = parent.EnsureChildren(relationshipsToInclude); + children.AddRange(affectedChildren); + } + } + + AssertRelationshipsFound(relationshipsFound, relationshipName, parents); + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, parents); + + return children; + } + + private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents) + { + if (relationshipsFound.Any()) + { + return; + } + + string[] parentPaths = parents.Select(parent => parent.Path).Distinct().Where(path => path != string.Empty).ToArray(); + string path = parentPaths.Length > 0 ? $"{parentPaths[0]}.{relationshipName}" : relationshipName; + + ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); + + bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); + + string message = ErrorFormatter.GetForNoneFound(ResourceFieldCategory.Relationship, relationshipName, path, parentResourceTypes, hasDerivedTypes); + throw new QueryParseException(message); + } + + private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, + ICollection parents) + { + if (relationshipsFound.All(relationship => !relationship.CanInclude)) + { + string parentPath = parents.First().Path; + ResourceType resourceType = relationshipsFound.First().LeftType; + + string message = parentPath == string.Empty + ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." + : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; + + throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); + } + } + + private static void ValidateMaximumIncludeDepth(int? maximumDepth, IncludeExpression include) { if (maximumDepth != null) { - foreach (ResourceFieldChainExpression chain in chains) + Stack parentChain = new(); + + foreach (IncludeElementExpression element in include.Elements) { - if (chain.Fields.Count > maximumDepth) - { - string path = string.Join('.', chain.Fields.Select(field => field.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); - } + ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth.Value); } } } + private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth) + { + parentChain.Push(includeElement.Relationship); + + if (parentChain.Count > maximumDepth) + { + string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); + } + + foreach (IncludeElementExpression child in includeElement.Children) + { + ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth); + } + + parentChain.Pop(); + } + protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleRelationshipCallback); + throw new NotSupportedException(); + } + + private sealed class IncludeTreeNode + { + private readonly IncludeTreeNode? _parent; + private readonly IDictionary _children = new Dictionary(); + + public RelationshipAttribute Relationship { get; } + + public string Path + { + get + { + var pathBuilder = new StringBuilder(); + IncludeTreeNode? parent = this; + + while (parent is { Relationship: not HiddenRootRelationship }) + { + pathBuilder.Insert(0, pathBuilder.Length > 0 ? $"{parent.Relationship.PublicName}." : parent.Relationship.PublicName); + parent = parent._parent; + } + + return pathBuilder.ToString(); + } + } + + private IncludeTreeNode(RelationshipAttribute relationship, IncludeTreeNode? parent) + { + Relationship = relationship; + _parent = parent; + } + + public static IncludeTreeNode CreateRoot(ResourceType resourceType) + { + var relationship = new HiddenRootRelationship(resourceType); + return new IncludeTreeNode(relationship, null); + } + + public ICollection EnsureChildren(ICollection relationships) + { + foreach (RelationshipAttribute relationship in relationships) + { + if (!_children.ContainsKey(relationship)) + { + var newChild = new IncludeTreeNode(relationship, this); + _children.Add(relationship, newChild); + } + } + + return _children.Where(pair => relationships.Contains(pair.Key)).Select(pair => pair.Value).ToList(); + } + + public IncludeExpression ToExpression() + { + IncludeElementExpression element = ToElementExpression(); + + if (element.Relationship is HiddenRootRelationship) + { + return new IncludeExpression(element.Children); + } + + return new IncludeExpression(ImmutableHashSet.Create(element)); + } + + private IncludeElementExpression ToElementExpression() + { + IImmutableSet elementChildren = _children.Values.Select(child => child.ToElementExpression()).ToImmutableHashSet(); + return new IncludeElementExpression(Relationship, elementChildren); + } + + public override string ToString() + { + IncludeExpression include = ToExpression(); + return include.ToFullString(); + } + + private sealed class HiddenRootRelationship : RelationshipAttribute + { + public HiddenRootRelationship(ResourceType rightType) + { + ArgumentGuard.NotNull(rightType, nameof(rightType)); + + RightType = rightType; + PublicName = "<>"; + } + } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs index dd0bda51b7..790f8f544d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs @@ -23,4 +23,5 @@ public static class Keywords public const string Any = "any"; public const string Count = "count"; public const string Has = "has"; + public const string IsType = "isType"; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index 4dc7230c24..681c1dd8f4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -31,17 +32,42 @@ protected virtual void Tokenize(string source) protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + var pathBuilder = new StringBuilder(); + EatFieldChain(pathBuilder, alternativeErrorMessage); + + IImmutableList chain = OnResolveFieldChain(pathBuilder.ToString(), chainRequirements); + + if (chain.Any()) { - IImmutableList chain = OnResolveFieldChain(token.Value!, chainRequirements); + return new ResourceFieldChainExpression(chain); + } + + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); + } - if (chain.Any()) + private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMessage) + { + while (true) + { + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + pathBuilder.Append(token.Value); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + pathBuilder.Append('.'); + } + else + { + return; + } + } + else { - return new ResourceFieldChainExpression(chain); + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); } protected CountExpression? TryParseCount() diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs index 6676cae30f..3f04ce92aa 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs @@ -14,6 +14,7 @@ public sealed class QueryTokenizer [')'] = TokenKind.CloseParen, ['['] = TokenKind.OpenBracket, [']'] = TokenKind.CloseBracket, + ['.'] = TokenKind.Period, [','] = TokenKind.Comma, [':'] = TokenKind.Colon, ['-'] = TokenKind.Minus diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs new file mode 100644 index 0000000000..6630cf2767 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +internal enum ResourceFieldCategory +{ + Field, + Attribute, + Relationship +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs new file mode 100644 index 0000000000..e15b14893a --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs @@ -0,0 +1,83 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing; + +internal sealed class ResourceFieldChainErrorFormatter +{ + public string GetForNotFound(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, + FieldChainInheritanceRequirement inheritanceRequirement) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append($" does not exist on resource type '{resourceType.PublicName}'"); + + if (inheritanceRequirement != FieldChainInheritanceRequirement.Disabled && resourceType.DirectlyDerivedTypes.Any()) + { + builder.Append(" or any of its derived types"); + } + + builder.Append('.'); + + return builder.ToString(); + } + + public string GetForMultipleMatches(ResourceFieldCategory category, string publicName, string path) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append(" is defined on multiple derived types."); + + return builder.ToString(); + } + + public string GetForWrongFieldType(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, string expected) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + builder.Append($" must be {expected} on resource type '{resourceType.PublicName}'."); + + return builder.ToString(); + } + + public string GetForNoneFound(ResourceFieldCategory category, string publicName, string path, ICollection parentResourceTypes, + bool hasDerivedTypes) + { + var builder = new StringBuilder(); + WriteSource(category, publicName, builder); + WritePath(path, publicName, builder); + + if (parentResourceTypes.Count == 1) + { + builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); + } + else + { + string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); + builder.Append($" does not exist on any of the resource types {typeNames}"); + } + + builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + + return builder.ToString(); + } + + private static void WriteSource(ResourceFieldCategory category, string publicName, StringBuilder builder) + { + builder.Append($"{category} '{publicName}'"); + } + + private static void WritePath(string path, string publicName, StringBuilder builder) + { + if (path != publicName) + { + builder.Append($" in '{path}'"); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index 33f3643aa8..4fb2632557 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -9,6 +9,34 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; ///
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 typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType); + selectors.IncludeAttribute(rightIdAttribute); + return new QueryLayer(relationship.RightType) { Include = IncludeExpression.Empty, Filter = filter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } + Selection = selection }; } @@ -442,27 +447,31 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); - object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + HashSet rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); + var secondarySelection = new FieldSelection(); + FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType); + secondarySelectors.IncludeAttribute(rightIdAttribute); + + QueryLayer secondaryLayer = new(hasManyRelationship.RightType) + { + Filter = rightFilter, + Selection = secondarySelection + }; + + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType); + primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer); + primarySelectors.IncludeAttribute(leftIdAttribute); + return new QueryLayer(hasManyRelationship.LeftType) { Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), Filter = leftFilter, - Projection = new Dictionary - { - [hasManyRelationship] = new(hasManyRelationship.RightType) - { - Filter = rightFilter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }, - [leftIdAttribute] = null - } + Selection = primarySelection }; } @@ -518,22 +527,36 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection? GetProjectionForSparseAttributeSet(ResourceType resourceType) +#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type + protected virtual FieldSelection? GetSelectionForSparseAttributeSet(ResourceType resourceType) +#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceType); + var selection = new FieldSelection(); - if (!fieldSet.Any()) + HashSet resourceTypes = resourceType.GetAllConcreteDerivedTypes().ToHashSet(); + resourceTypes.Add(resourceType); + + foreach (ResourceType nextType in resourceTypes) { - return null; - } + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); - HashSet attributeSet = fieldSet.OfType().ToHashSet(); - AttrAttribute idAttribute = GetIdAttribute(resourceType); - attributeSet.Add(idAttribute); + if (!fieldSet.Any()) + { + continue; + } + + HashSet attributeSet = fieldSet.OfType().ToHashSet(); + + FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); + selectors.IncludeAttributes(attributeSet); + + AttrAttribute idAttribute = GetIdAttribute(nextType); + selectors.IncludeAttribute(idAttribute); + } - return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer?)null); + return selection.IsEmpty ? null : selection; } private static AttrAttribute GetIdAttribute(ResourceType resourceType) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs index 6384600a58..14c37bb70d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs @@ -37,44 +37,54 @@ public Expression ApplyInclude(IncludeExpression include) public override Expression VisitInclude(IncludeExpression expression, object? argument) { - Expression source = ApplyEagerLoads(_source, _resourceType.EagerLoads, null); + // De-duplicate chains coming from derived relationships. + HashSet propertyPaths = new(); + + ApplyEagerLoads(_resourceType.EagerLoads, null, propertyPaths); foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) { - source = ProcessRelationshipChain(chain, source); + ProcessRelationshipChain(chain, propertyPaths); } - return source; + return ToExpression(propertyPaths); } - private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) + private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) { string? path = null; - Expression result = source; foreach (RelationshipAttribute relationship in chain.Fields.Cast()) { path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; - result = ApplyEagerLoads(result, relationship.RightType.EagerLoads, path); + ApplyEagerLoads(relationship.RightType.EagerLoads, path, outputPropertyPaths); } - return IncludeExtensionMethodCall(result, path!); + outputPropertyPaths.Add(path!); } - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string? pathPrefix) + private void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) { - Expression result = source; - foreach (EagerLoadAttribute eagerLoad in eagerLoads) { string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; - result = IncludeExtensionMethodCall(result, path); + outputPropertyPaths.Add(path); + + ApplyEagerLoads(eagerLoad.Children, path, outputPropertyPaths); + } + } + + private Expression ToExpression(HashSet propertyPaths) + { + Expression source = _source; - result = ApplyEagerLoads(result, eagerLoad.Children, path); + foreach (string propertyPath in propertyPaths) + { + source = IncludeExtensionMethodCall(source, propertyPath); } - return result; + return source; } private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs index 8be9e4263e..e5502031a3 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs @@ -14,15 +14,30 @@ public sealed class LambdaScope : IDisposable public ParameterExpression Parameter { get; } public Expression Accessor { get; } - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) + private LambdaScope(LambdaParameterNameScope parameterNameScope, ParameterExpression parameter, Expression accessor) + { + _parameterNameScope = parameterNameScope; + Parameter = parameter; + Accessor = accessor; + } + + public static LambdaScope Create(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) { ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); ArgumentGuard.NotNull(elementType, nameof(elementType)); - _parameterNameScope = nameFactory.Create(elementType.Name); - Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); + LambdaParameterNameScope parameterNameScope = nameFactory.Create(elementType.Name); + ParameterExpression parameter = Expression.Parameter(elementType, parameterNameScope.Name); + Expression accessor = accessorExpression ?? parameter; + + return new LambdaScope(parameterNameScope, parameter, accessor); + } + + public LambdaScope WithAccessor(Expression accessorExpression) + { + ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression)); - Accessor = accessorExpression ?? Parameter; + return new LambdaScope(_parameterNameScope, Parameter, accessorExpression); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs index 3ba7c5aab9..9c13a63d28 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs @@ -19,6 +19,6 @@ public LambdaScope CreateScope(Type elementType, Expression? accessorExpression { ArgumentGuard.NotNull(elementType, nameof(elementType)); - return new LambdaScope(_nameFactory, elementType, accessorExpression); + return LambdaScope.Create(_nameFactory, elementType, accessorExpression); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs index bf14c70b6d..d04ff57e9d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; @@ -9,7 +10,7 @@ namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; /// 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