diff --git a/Directory.Build.props b/Directory.Build.props index 6c68a1507..dc4843340 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,8 @@ $(MSBuildThisFileDirectory)CodingGuidelines.ruleset $(MSBuildThisFileDirectory)tests.runsettings 5.6.1 + direct + $(NoWarn);NETSDK1215 diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 31abaa8dc..7cbbf8235 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -672,6 +672,7 @@ $left$ = $right$; if ($argument$ is null) throw new ArgumentNullException(nameof($argument$)); WARNING True + True True True True diff --git a/README.md b/README.md index 5ae9184e0..3354eac45 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,16 @@ See also our [versioning policy](./VERSIONING_POLICY.md). | | | 7 | 7 | | 5.5+ | Stable | 6 | 6, 7 | | | | 7 | 7 | -| | | 8 | 8 | +| | | 8 | 8, 9 | +| | | 9 | 9 | | master | Preview | 6 | 6, 7 | | | | 7 | 7 | -| | | 8 | 8 | +| | | 8 | 8, 9 | +| | | 9 | 9 | | openapi | Experimental | 6 | 6, 7 | | | | 7 | 7 | -| | | 8 | 8 | +| | | 8 | 8, 9 | +| | | 9 | 9 | ## Contributing diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs index 1da38697d..7a46e69a5 100644 --- a/src/Examples/DapperExample/Repositories/DapperRepository.cs +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -103,7 +103,6 @@ public sealed partial class DapperRepository : IResourceReposito private readonly SqlCaptureStore _captureStore; private readonly ILoggerFactory _loggerFactory; private readonly ILogger> _logger; - private readonly CollectionConverter _collectionConverter = new(); private readonly ParameterFormatter _parameterFormatter = new(); private readonly DapperFacade _dapperFacade; @@ -270,12 +269,12 @@ private async Task ApplyTargetedFieldsAsync(TResource resourceFromRequest, TReso if (relationship is HasManyAttribute hasManyRelationship) { - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); - return _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + return CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); } return rightValue; @@ -464,7 +463,9 @@ public async Task AddToToManyRelationshipAsync(TResource? leftResource, [Disallo leftPlaceholderResource.Id = leftId; await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); - relationship.SetValue(leftPlaceholderResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + relationship.SetValue(leftPlaceholderResource, + CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); await _resourceDefinitionAccessor.OnWritingAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); @@ -500,7 +501,7 @@ public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken); - relationship.SetValue(leftResource, _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + relationship.SetValue(leftResource, CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs index 6b075d6ca..73213445e 100644 --- a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -12,7 +12,6 @@ namespace DapperExample.Repositories; /// internal sealed class ResourceChangeDetector { - private readonly CollectionConverter _collectionConverter = new(); private readonly IDataModelService _dataModelService; private Dictionary _currentColumnValues = []; @@ -69,7 +68,7 @@ private Dictionary> CaptureRightRe foreach (RelationshipAttribute relationship in ResourceType.Relationships) { object? rightValue = relationship.GetValue(resource); - HashSet rightResources = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResources = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); relationshipValues[relationship] = rightResources; } diff --git a/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs b/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs index 69898f107..83b3d6264 100644 --- a/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs +++ b/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs @@ -34,28 +34,27 @@ private static string WritePeople(PersonCollectionResponseDocument? peopleRespon return builder.ToString(); } - private static void WritePerson(PersonDataInResponse person, ICollection includes, StringBuilder builder) + private static void WritePerson(PersonDataInResponse person, List includes, StringBuilder builder) { - ICollection assignedTodoItems = person.Relationships?.AssignedTodoItems?.Data ?? []; + List assignedTodoItems = person.Relationships?.AssignedTodoItems?.Data ?? []; builder.AppendLine($" Person {person.Id}: {person.Attributes?.DisplayName} with {assignedTodoItems.Count} assigned todo-items:"); WriteRelatedTodoItems(assignedTodoItems, includes, builder); } - private static void WriteRelatedTodoItems(IEnumerable todoItemIdentifiers, ICollection includes, - StringBuilder builder) + private static void WriteRelatedTodoItems(List todoItemIdentifiers, List includes, StringBuilder builder) { foreach (TodoItemIdentifierInResponse todoItemIdentifier in todoItemIdentifiers) { TodoItemDataInResponse includedTodoItem = includes.OfType().Single(include => include.Id == todoItemIdentifier.Id); - ICollection tags = includedTodoItem.Relationships?.Tags?.Data ?? []; + List tags = includedTodoItem.Relationships?.Tags?.Data ?? []; builder.AppendLine($" TodoItem {includedTodoItem.Id}: {includedTodoItem.Attributes?.Description} with {tags.Count} tags:"); WriteRelatedTags(tags, includes, builder); } } - private static void WriteRelatedTags(IEnumerable tagIdentifiers, ICollection includes, StringBuilder builder) + private static void WriteRelatedTags(List tagIdentifiers, List includes, StringBuilder builder) { foreach (TagIdentifierInResponse tagIdentifier in tagIdentifiers) { diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index 750b896c2..f055c6548 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -15,6 +15,12 @@ internal sealed class CollectionConverter typeof(IEnumerable<>) ]; + public static CollectionConverter Instance { get; } = new(); + + private CollectionConverter() + { + } + /// /// Creates a collection instance based on the specified collection type and copies the specified elements into it. /// @@ -22,7 +28,11 @@ internal sealed class CollectionConverter /// Source to copy from. /// /// - /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). + /// Target collection type, for example: ) + /// ]]> or ) + /// ]]>. /// public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) { @@ -41,7 +51,12 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType } /// - /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} + /// Returns a compatible collection type that can be instantiated, for example: -> List
+ /// ]]> or + /// -> HashSet
+ /// ]]>. ///
private Type ToConcreteCollectionType(Type collectionType) { @@ -80,7 +95,12 @@ public IReadOnlyCollection ExtractResources(object? value) } /// - /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. + /// Returns the element type if the specified type is a generic collection, for example: -> string + /// ]]> or + /// null + /// ]]>. /// public Type? FindCollectionElementType(Type? type) { @@ -96,8 +116,12 @@ public IReadOnlyCollection ExtractResources(object? value) } /// - /// Indicates whether a instance can be assigned to the specified type, for example IList{Article} -> false or ISet{Article} -> - /// true. + /// Indicates whether a instance can be assigned to the specified type, for example: + /// -> false + /// ]]> or -> true + /// ]]>. /// public bool TypeCanContainHashSet(Type collectionType) { diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index 43161f99a..274a6bb16 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -59,7 +59,7 @@ private bool EvaluateIsManyToMany() { if (InverseNavigationProperty != null) { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType != null; } @@ -103,14 +103,14 @@ public void AddValue(object resource, IIdentifiable resourceToAdd) ArgumentGuard.NotNull(resourceToAdd); object? rightValue = GetValue(resource); - List rightResources = CollectionConverter.ExtractResources(rightValue).ToList(); + List rightResources = CollectionConverter.Instance.ExtractResources(rightValue).ToList(); if (!rightResources.Exists(nextResource => nextResource == resourceToAdd)) { rightResources.Add(resourceToAdd); Type collectionType = rightValue?.GetType() ?? Property.PropertyType; - IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(rightResources, collectionType); + IEnumerable typedCollection = CollectionConverter.Instance.CopyToTypedCollection(rightResources, collectionType); base.SetValue(resource, typedCollection); } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs index 72212c76f..51d22f995 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -57,7 +57,7 @@ private bool EvaluateIsOneToOne() { if (InverseNavigationProperty != null) { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType == null; } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index 492af08c6..72bdecf7b 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -12,8 +12,6 @@ namespace JsonApiDotNetCore.Resources.Annotations; [PublicAPI] public abstract class RelationshipAttribute : ResourceFieldAttribute { - private protected static readonly CollectionConverter CollectionConverter = new(); - // This field is definitely assigned after building the resource graph, which is why its public equivalent is declared as non-nullable. private ResourceType? _rightType; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 3eeaa77fb..abc40c481 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -10,7 +10,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; public class SetRelationshipProcessor : ISetRelationshipProcessor where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly ISetRelationshipService _service; public SetRelationshipProcessor(ISetRelationshipService service) @@ -40,7 +39,7 @@ public SetRelationshipProcessor(ISetRelationshipService service) if (relationship is HasManyAttribute) { - IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); return rightResources.ToHashSet(IdentifiableComparer.Instance); } diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index d55243e30..ccf23e1eb 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -319,8 +319,6 @@ private sealed class ArrayIndexerSegment( Func? getCollectionElementTypeCallback) : ModelStateKeySegment(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) { - private static readonly CollectionConverter CollectionConverter = new(); - public int ArrayIndex { get; } = arrayIndex; public Type GetCollectionElementType() @@ -333,7 +331,7 @@ private Type GetDeclaredCollectionElementType() { if (ModelType != typeof(string)) { - Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(ModelType); if (elementType != null) { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs index 5a3c08ee2..9cc366b28 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs @@ -1,4 +1,5 @@ using System.Net; +using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Serialization.Objects; @@ -8,6 +9,7 @@ namespace JsonApiDotNetCore.Middleware; /// +[PublicAPI] public class JsonApiContentNegotiator : IJsonApiContentNegotiator { private readonly IJsonApiOptions _options; @@ -71,9 +73,9 @@ private IReadOnlySet ValidateAcceptHeader(IReadOnlyLi string[] acceptHeaderValues = HttpContext.Request.Headers.GetCommaSeparatedValues("Accept"); JsonApiMediaType? bestMatch = null; - if (acceptHeaderValues.Length == 0 && possibleMediaTypes.Contains(JsonApiMediaType.Default)) + if (acceptHeaderValues.Length == 0) { - bestMatch = JsonApiMediaType.Default; + bestMatch = GetDefaultMediaType(possibleMediaTypes, requestMediaType); } else { @@ -149,6 +151,23 @@ private IReadOnlySet ValidateAcceptHeader(IReadOnlyLi return bestMatch.Extensions; } + /// + /// Returns the JSON:API media type (possibly including extensions) to use when no Accept header was sent. + /// + /// + /// The media types returned from . + /// + /// + /// The media type from in the Content-Type header. + /// + /// + /// The default media type to use, or null if not available. + /// + protected virtual JsonApiMediaType? GetDefaultMediaType(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) + { + return possibleMediaTypes.Contains(JsonApiMediaType.Default) ? JsonApiMediaType.Default : null; + } + /// /// Gets the list of possible combinations of JSON:API extensions that are available at the current endpoint. The set of extensions in the request body /// must always be the same as in the response body. diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs index 99674b239..6afa8a069 100644 --- a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -122,7 +122,7 @@ private static ReadOnlyCollection LookupRelationshipName(string { // 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); + HashSet relationships = GetRelationshipsInConcreteTypes(parent.Relationship.RightType, relationshipName); if (relationships.Count > 0) { @@ -140,6 +140,32 @@ private static ReadOnlyCollection LookupRelationshipName(string return children.AsReadOnly(); } + private static HashSet GetRelationshipsInConcreteTypes(ResourceType resourceType, string relationshipName) + { + HashSet relationshipsToInclude = []; + + foreach (RelationshipAttribute relationship in resourceType.GetRelationshipsInTypeOrDerived(relationshipName)) + { + if (!relationship.LeftType.ClrType.IsAbstract) + { + relationshipsToInclude.Add(relationship); + } + + IncludeRelationshipsFromConcreteDerivedTypes(relationship, relationshipsToInclude); + } + + return relationshipsToInclude; + } + + private static void IncludeRelationshipsFromConcreteDerivedTypes(RelationshipAttribute relationship, HashSet relationshipsToInclude) + { + foreach (ResourceType derivedType in relationship.LeftType.GetAllConcreteDerivedTypes()) + { + RelationshipAttribute relationshipInDerived = derivedType.GetRelationshipByPublicName(relationship.PublicName); + relationshipsToInclude.Add(relationshipInDerived); + } + } + private static void AssertRelationshipsFound(HashSet relationshipsFound, string relationshipName, IReadOnlyCollection parents, int position) { diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index 80125cde5..1804027de 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -14,7 +14,6 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public class QueryLayerComposer : IQueryLayerComposer { - private readonly CollectionConverter _collectionConverter = new(); private readonly IQueryConstraintProvider[] _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; @@ -213,7 +212,7 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< foreach (IncludeElementExpression includeElement in includeElementsEvaluated) { parentLayer.Selection ??= new FieldSelection(); - FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(parentLayer.ResourceType); + FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(includeElement.Relationship.LeftType); if (!selectors.ContainsField(includeElement.Relationship)) { @@ -413,7 +412,7 @@ public QueryLayer ComposeForUpdate([DisallowNull] TId id, ResourceType prim foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { object? rightValue = relationship.GetValue(primaryResource); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); if (rightResourceIds.Count > 0) { diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs index 05cccf794..b218b09ca 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -61,6 +61,7 @@ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, T ArgumentGuard.NotNull(lambdaScopeFactory); ArgumentGuard.NotNull(lambdaScope); ArgumentGuard.NotNull(queryableBuilder); + AssertSameType(source.Type, resourceType); Source = source; ResourceType = resourceType; @@ -72,6 +73,17 @@ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, T State = state; } + private static void AssertSameType(Type sourceType, ResourceType resourceType) + { + Type? sourceElementType = CollectionConverter.Instance.FindCollectionElementType(sourceType); + + if (sourceElementType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between expression type '{sourceElementType?.Name}' and resource type '{resourceType.ClrType.Name}'."); + } + } + public QueryClauseBuilderContext WithSource(Expression source) { ArgumentGuard.NotNull(source); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs index 6b52b3d69..c04eb6882 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -35,6 +35,7 @@ public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext c { ArgumentGuard.NotNull(layer); ArgumentGuard.NotNull(context); + AssertSameType(layer.ResourceType, context.ElementType); Expression expression = context.Source; @@ -66,6 +67,15 @@ public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext c return expression; } + private static void AssertSameType(ResourceType resourceType, Type elementType) + { + if (elementType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between query layer type '{resourceType.ClrType.Name}' and query element type '{elementType.Name}'."); + } + } + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType, QueryableBuilderContext context) { ArgumentGuard.NotNull(source); diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs index 7e099fb09..9d2498620 100644 --- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -14,7 +14,6 @@ public class SelectClauseBuilder : QueryClauseBuilder, ISelectClauseBuilder { 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); private readonly IResourceFactory _resourceFactory; @@ -30,36 +29,47 @@ public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuild { ArgumentGuard.NotNull(selection); - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, context.LambdaScope, false, context); + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, false, context); LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); return SelectExtensionMethodCall(context.ExtensionType, context.Source, context.LambdaScope.Parameter.Type, lambda); } - private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, - bool lambdaAccessorRequiresTestForNull, QueryClauseBuilderContext context) + private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, bool lambdaAccessorRequiresTestForNull, + QueryClauseBuilderContext context) { + AssertSameType(context.LambdaScope.Accessor.Type, resourceType); + IReadOnlyEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!; IReadOnlyEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); Expression bodyInitializer = concreteEntityTypes.Length > 1 - ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope, context) - : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope, context); + ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, context) + : CreateLambdaBodyInitializerForSingleType(selection, resourceType, context); if (!lambdaAccessorRequiresTestForNull) { return bodyInitializer; } - return TestForNull(lambdaScope.Accessor, bodyInitializer); + return TestForNull(context.LambdaScope.Accessor, bodyInitializer); + } + + private static void AssertSameType(Type lambdaAccessorType, ResourceType resourceType) + { + if (lambdaAccessorType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between lambda accessor type '{lambdaAccessorType.Name}' and resource type '{resourceType.ClrType.Name}'."); + } } private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, - IEnumerable concreteEntityTypes, LambdaScope lambdaScope, QueryClauseBuilderContext context) + IEnumerable concreteEntityTypes, QueryClauseBuilderContext context) { IReadOnlySet resourceTypes = selection.GetResourceTypes(); - Expression rootCondition = lambdaScope.Accessor; + Expression rootCondition = context.LambdaScope.Accessor; foreach (IReadOnlyEntityType entityType in concreteEntityTypes) { @@ -74,14 +84,14 @@ private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection se Dictionary.ValueCollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType, context.EntityModel); - MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, context)) .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); + BinaryExpression typeCheck = CreateRuntimeTypeCheck(context.LambdaScope, entityType.ClrType); rootCondition = Expression.Condition(typeCheck, castToBaseType, rootCondition); } } @@ -101,18 +111,16 @@ private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); } - private MemberInitExpression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, + private MemberInitExpression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, QueryClauseBuilderContext context) { FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); Dictionary.ValueCollection propertySelectors = - ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type, context.EntityModel); - - MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope, context)) - .Cast().ToArray(); + ToPropertySelectors(fieldSelectors, resourceType, context.LambdaScope.Accessor.Type, context.EntityModel); - NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, context)).Cast().ToArray(); + NewExpression createInstance = _resourceFactory.CreateNewExpression(context.LambdaScope.Accessor.Type); return Expression.MemberInit(createInstance, propertyAssignments); } @@ -183,35 +191,40 @@ private static void IncludeEagerLoads(ResourceType resourceType, Dictionary : IResourceRepository, IRepositorySupportsTransaction where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; @@ -250,7 +249,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r if (relationship is HasManyAttribute hasManyRelationship) { - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); @@ -482,14 +481,14 @@ private ISet GetRightValueToStoreForAddToToMany(TResource leftRes object? rightValueStored = relationship.GetValue(leftResource); // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true + // @formatter:wrap_after_property_in_chained_method_calls true - HashSet rightResourceIdsStored = _collectionConverter + HashSet rightResourceIdsStored = CollectionConverter.Instance .ExtractResources(rightValueStored) .Select(_dbContext.GetTrackedOrAttach) .ToHashSet(IdentifiableComparer.Instance); - // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore if (rightResourceIdsStored.Count > 0) @@ -531,18 +530,18 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour object? rightValueStored = relationship.GetValue(leftResourceTracked); // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true + // @formatter:wrap_after_property_in_chained_method_calls true - IIdentifiable[] rightResourceIdsStored = _collectionConverter + IIdentifiable[] rightResourceIdsStored = CollectionConverter.Instance .ExtractResources(rightValueStored) .Concat(extraResourceIdsToRemove) .Select(_dbContext.GetTrackedOrAttach) .ToArray(); - // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore - rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); + rightValueStored = CollectionConverter.Instance.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); relationship.SetValue(leftResourceTracked, rightValueStored); MarkRelationshipAsLoaded(leftResourceTracked, relationship); @@ -624,11 +623,11 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, return null; } - IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); IIdentifiable[] rightResourcesTracked = rightResources.Select(_dbContext.GetTrackedOrAttach).ToArray(); return rightValue is IEnumerable - ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + ? CollectionConverter.Instance.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) : rightResourcesTracked.Single(); } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index a58542c23..df08f04f2 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -11,6 +11,11 @@ namespace JsonApiDotNetCore.Repositories; /// public interface IResourceRepositoryAccessor { + /// + /// Uses the to lookup the corresponding for the specified CLR type. + /// + ResourceType LookupResourceType(Type resourceClrType); + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 23d03eb9d..827c2259f 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -29,6 +29,14 @@ public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGra _request = request; } + /// + public ResourceType LookupResourceType(Type resourceClrType) + { + ArgumentGuard.NotNull(resourceClrType); + + return _resourceGraph.GetResourceType(resourceClrType); + } + /// public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index 88ea29ecd..2c8ec00d1 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -10,8 +10,6 @@ namespace JsonApiDotNetCore.Resources; [PublicAPI] public sealed class OperationContainer { - private static readonly CollectionConverter CollectionConverter = new(); - public IIdentifiable Resource { get; } public ITargetedFields TargetedFields { get; } public IJsonApiRequest Request { get; } @@ -56,7 +54,7 @@ public ISet GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) { object? rightValue = relationship.GetValue(Resource); - IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); secondaryResources.UnionWith(rightResources); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index b0b230094..02b3e80e4 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -9,8 +9,6 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter { - private static readonly CollectionConverter CollectionConverter = new(); - private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) @@ -109,7 +107,7 @@ private IEnumerable ConvertToManyRelationshipData(SingleOrManyData(IdentifiableComparer.Instance); diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 0e4e605d9..3556eadaf 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -18,8 +18,6 @@ namespace JsonApiDotNetCore.Serialization.Response; [PublicAPI] public class ResponseModelAdapter : IResponseModelAdapter { - private static readonly CollectionConverter CollectionConverter = new(); - private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; private readonly ILinkBuilder _linkBuilder; @@ -304,7 +302,7 @@ private void TraverseRelationship(RelationshipAttribute relationship, IIdentifia } object? rightValue = effectiveRelationship.GetValue(leftResource); - IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); leftTreeNode.EnsureHasRelationship(effectiveRelationship); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2fc11684f..4fa0cdc4f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -21,7 +21,6 @@ namespace JsonApiDotNetCore.Services; public class JsonApiResourceService : IResourceService where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; @@ -59,10 +58,10 @@ public virtual async Task> GetAsync(CancellationT { _traceWriter.LogMethodStart(); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + if (_options.IncludeTotalResourceCount) { FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); @@ -107,12 +106,12 @@ public virtual async Task GetAsync([DisallowNull] TId id, Cancellatio relationshipName }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); - ArgumentGuard.NotNull(relationshipName); AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + if (_options.IncludeTotalResourceCount && _request.IsCollection) { await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); @@ -147,12 +146,12 @@ public virtual async Task GetAsync([DisallowNull] TId id, Cancellatio relationshipName }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); - ArgumentGuard.NotNull(relationshipName); AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + if (_options.IncludeTotalResourceCount && _request.IsCollection) { await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); @@ -280,7 +279,7 @@ private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync if (!onlyIfTypeHierarchy || relationship.RightType.IsPartOfTypeHierarchy()) { object? rightValue = relationship.GetValue(primaryResource); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); if (rightResourceIds.Count > 0) { @@ -293,7 +292,7 @@ private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync // 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); + : CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); relationship.SetValue(primaryResource, newRightValue); } @@ -350,12 +349,12 @@ public virtual async Task AddToToManyRelationshipAsync([DisallowNull] TId leftId rightResourceIds }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); - ArgumentGuard.NotNull(relationshipName); ArgumentGuard.NotNull(rightResourceIds); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); + TResource? resourceFromDatabase = null; if (rightResourceIds.Count > 0 && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) @@ -401,7 +400,7 @@ private async Task RemoveExistingIdsFromRelationshipRightSideAsync(Ha TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); object? rightValue = hasManyRelationship.GetValue(leftResource); - IReadOnlyCollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); + IReadOnlyCollection existingRightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); @@ -422,7 +421,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR { AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); object? newRightValue = rightValue; if (rightResourceIds.Count > 0) @@ -436,7 +435,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR // 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); + : CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType); if (missingResources.Count > 0) { @@ -500,11 +499,11 @@ public virtual async Task SetRelationshipAsync([DisallowNull] TId leftId, string rightValue }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); - ArgumentGuard.NotNull(relationshipName); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); + 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. @@ -535,10 +534,10 @@ public virtual async Task DeleteAsync([DisallowNull] TId id, CancellationToken c id }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + TResource? resourceFromDatabase = null; if (_request.PrimaryResourceType.IsPartOfTypeHierarchy()) @@ -571,11 +570,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync([DisallowNull] TId l rightResourceIds }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); - ArgumentGuard.NotNull(relationshipName); ArgumentGuard.NotNull(rightResourceIds); AssertHasRelationship(_request.Relationship, relationshipName); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + var hasManyRelationship = (HasManyAttribute)_request.Relationship; TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); @@ -600,9 +600,10 @@ protected async Task GetPrimaryResourceByIdAsync([DisallowNull] TId i private async Task GetPrimaryResourceByIdOrDefaultAsync([DisallowNull] TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + // Using the non-accurized resource type, so that includes on sibling derived types can be used at abstract endpoint. + ResourceType resourceType = _repositoryAccessor.LookupResourceType(typeof(TResource)); - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, resourceType, fieldSelection); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); return primaryResources.SingleOrDefault(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs index ec5c1c46f..ec541d443 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceReadTests.cs @@ -20,6 +20,8 @@ protected ResourceInheritanceReadTests(IntegrationTestContext(); + testContext.UseController(); testContext.UseController(); testContext.UseController(); @@ -380,6 +382,53 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Relationships.ShouldOnlyContainKeys("manufacturer", "wheels", "cargoBox", "lights", "foldingDimensions", "features"); } + [Fact] + public async Task Can_get_primary_resource_with_derived_includes() + { + // Arrange + VehicleManufacturer manufacturer = _fakers.VehicleManufacturer.GenerateOne(); + + Bike bike = _fakers.Bike.GenerateOne(); + bike.Lights = _fakers.BicycleLight.GenerateSet(15); + manufacturer.Vehicles.Add(bike); + + Tandem tandem = _fakers.Tandem.GenerateOne(); + tandem.Features = _fakers.GenericFeature.GenerateSet(15); + manufacturer.Vehicles.Add(tandem); + + Car car = _fakers.Car.GenerateOne(); + car.Engine = _fakers.GasolineEngine.GenerateOne(); + car.Features = _fakers.GenericFeature.GenerateSet(15); + manufacturer.Vehicles.Add(car); + + Truck truck = _fakers.Truck.GenerateOne(); + truck.Engine = _fakers.GasolineEngine.GenerateOne(); + truck.Features = _fakers.GenericFeature.GenerateSet(15); + manufacturer.Vehicles.Add(truck); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.VehicleManufacturers.Add(manufacturer); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/vehicleManufacturers/{manufacturer.StringId}?include=vehicles.lights,vehicles.features"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("vehicleManufacturers"); + responseDocument.Data.SingleValue.Id.Should().Be(manufacturer.StringId); + + responseDocument.Included.ShouldNotBeNull(); + responseDocument.Included.Where(include => include.Type == "bicycleLights").Should().HaveCount(10); + responseDocument.Included.Where(include => include.Type == "genericFeatures").Should().HaveCount(10 * 3); + } + [Fact] public async Task Can_get_secondary_resource_at_abstract_base_endpoint() { @@ -1716,6 +1765,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => "id": "{{bike.Manufacturer.StringId}}", "attributes": { "name": "{{bike.Manufacturer.Name}}" + }, + "relationships": { + "vehicles": { + "links": { + "self": "/vehicleManufacturers/{{bike.Manufacturer.StringId}}/relationships/vehicles", + "related": "/vehicleManufacturers/{{bike.Manufacturer.StringId}}/vehicles" + } + } + }, + "links": { + "self": "/vehicleManufacturers/{{bike.Manufacturer.StringId}}" } }, { @@ -1822,6 +1882,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => "id": "{{car.Manufacturer.StringId}}", "attributes": { "name": "{{car.Manufacturer.Name}}" + }, + "relationships": { + "vehicles": { + "links": { + "self": "/vehicleManufacturers/{{car.Manufacturer.StringId}}/relationships/vehicles", + "related": "/vehicleManufacturers/{{car.Manufacturer.StringId}}/vehicles" + } + } + }, + "links": { + "self": "/vehicleManufacturers/{{car.Manufacturer.StringId}}" } }, { @@ -1903,6 +1974,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => "id": "{{tandem.Manufacturer.StringId}}", "attributes": { "name": "{{tandem.Manufacturer.Name}}" + }, + "relationships": { + "vehicles": { + "links": { + "self": "/vehicleManufacturers/{{tandem.Manufacturer.StringId}}/relationships/vehicles", + "related": "/vehicleManufacturers/{{tandem.Manufacturer.StringId}}/vehicles" + } + } + }, + "links": { + "self": "/vehicleManufacturers/{{tandem.Manufacturer.StringId}}" } }, { @@ -1997,6 +2079,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => "id": "{{truck.Manufacturer.StringId}}", "attributes": { "name": "{{truck.Manufacturer.Name}}" + }, + "relationships": { + "vehicles": { + "links": { + "self": "/vehicleManufacturers/{{truck.Manufacturer.StringId}}/relationships/vehicles", + "related": "/vehicleManufacturers/{{truck.Manufacturer.StringId}}/vehicles" + } + } + }, + "links": { + "self": "/vehicleManufacturers/{{truck.Manufacturer.StringId}}" } }, { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs index a5cffa039..c0620b12e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceInheritance/ResourceInheritanceWriteTests.cs @@ -108,7 +108,7 @@ public async Task Cannot_create_abstract_resource_at_abstract_endpoint() } [Fact] - public async Task Can_create_concrete_base_resource_at_abstract_endpoint_with_relationships() + public async Task Can_create_concrete_base_resource_at_abstract_endpoint_with_relationships_and_includes() { // Arrange var bikeStore = _testContext.Factory.Services.GetRequiredService>(); @@ -184,7 +184,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/vehicles"; + const string route = "/vehicles?include=manufacturer,wheels,engine,navigationSystem,features,sleepingArea,cargoBox,lights,foldingDimensions"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -238,7 +238,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_concrete_derived_resource_at_abstract_endpoint_with_relationships() + public async Task Can_create_concrete_derived_resource_at_abstract_endpoint_with_relationships_and_includes() { // Arrange var carStore = _testContext.Factory.Services.GetRequiredService>(); @@ -325,7 +325,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/vehicles"; + const string route = "/vehicles?include=manufacturer,wheels,engine,navigationSystem,features,sleepingArea,cargoBox,lights,foldingDimensions"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -385,7 +385,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_concrete_derived_resource_at_concrete_base_endpoint_with_relationships() + public async Task Can_create_concrete_derived_resource_at_concrete_base_endpoint_with_relationships_and_includes() { // Arrange var tandemStore = _testContext.Factory.Services.GetRequiredService>(); @@ -475,7 +475,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/bikes"; + const string route = "/bikes?include=manufacturer,wheels,cargoBox,lights,foldingDimensions,features"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -980,7 +980,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_update_concrete_base_resource_at_abstract_endpoint_with_relationships() + public async Task Can_update_concrete_base_resource_at_abstract_endpoint_with_relationships_and_includes() { // Arrange var bikeStore = _testContext.Factory.Services.GetRequiredService>(); @@ -1059,7 +1059,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/vehicles/{existingBike.StringId}"; + string route = + $"/vehicles/{existingBike.StringId}?include=manufacturer,wheels,engine,navigationSystem,features,sleepingArea,cargoBox,lights,foldingDimensions"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -1108,7 +1109,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_update_concrete_base_resource_stored_as_concrete_derived_at_abstract_endpoint_with_relationships() + public async Task Can_update_concrete_base_resource_stored_as_concrete_derived_at_abstract_endpoint_with_relationships_and_includes() { // Arrange var tandemStore = _testContext.Factory.Services.GetRequiredService>(); @@ -1187,7 +1188,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/vehicles/{existingTandem.StringId}"; + string route = + $"/vehicles/{existingTandem.StringId}?include=manufacturer,wheels,engine,navigationSystem,features,sleepingArea,cargoBox,lights,foldingDimensions"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs index 096f46995..fc3431da3 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -93,9 +93,9 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin [InlineData("includes", "posts.comments", "posts.comments")] [InlineData("includes", "posts,posts.comments", "posts.comments")] [InlineData("includes", "posts,posts.labels,posts.comments", "posts.comments,posts.labels")] - [InlineData("includes", "owner.person.children.husband", "owner.person.children.husband")] + [InlineData("includes", "owner.person.children.husband", "owner.person.children.husband,owner.person.children.husband")] [InlineData("includes", "owner.person.wife,owner.person.husband", "owner.person.husband,owner.person.wife")] - [InlineData("includes", "owner.person.father.children.wife", "owner.person.father.children.wife")] + [InlineData("includes", "owner.person.father.children.wife", "owner.person.father.children.wife,owner.person.father.children.wife")] [InlineData("includes", "owner.person.friends", "owner.person.friends,owner.person.friends")] [InlineData("includes", "owner.person.friends.friends", "owner.person.friends.friends,owner.person.friends.friends,owner.person.friends.friends,owner.person.friends.friends")]