From df66a9f65a46848ca8a59f7787e26c94cc4f2096 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 8 Dec 2020 18:24:02 +0100 Subject: [PATCH 1/2] Changes the use of fields query string parameter to be json:api spec compliant. Before we'd accept a relationship path between the square brackets and would only allow attribute names in the value. Now we expect a resource type between square brackets and the value can contain both attributes and relationships. --- benchmarks/Query/QueryParserBenchmarks.cs | 8 +- docs/internals/queries.md | 4 +- .../004_GET_Books-PublishYear.ps1 | 2 +- .../reading/sparse-fieldset-selection.md | 18 +- docs/usage/resources/attributes.md | 2 +- docs/usage/resources/resource-definitions.md | 2 +- docs/usage/writing/creating.md | 4 +- docs/usage/writing/updating.md | 4 +- .../Models/Passport.cs | 2 - ...sourcesInRelationshipsNotFoundException.cs | 1 - .../Expressions/QueryExpressionRewriter.cs | 25 ++ .../Expressions/QueryExpressionVisitor.cs | 5 + .../Expressions/SparseFieldSetExpression.cs | 20 +- .../SparseFieldSetExpressionExtensions.cs | 48 ++-- .../Expressions/SparseFieldTableExpression.cs | 81 ++++++ .../Parsing/ResourceFieldChainResolver.cs | 2 +- .../Internal/Parsing/SparseFieldSetParser.cs | 34 +-- .../Internal/Parsing/SparseFieldTypeParser.cs | 71 +++++ .../Queries/Internal/QueryLayerComposer.cs | 32 +-- .../Queries/Internal/SparseFieldSetCache.cs | 125 +++++++++ ...parseFieldSetQueryStringParameterReader.cs | 40 +-- .../Building/IncludedResourceObjectBuilder.cs | 42 ++- .../Building/ResponseResourceObjectBuilder.cs | 23 +- .../Serialization/FieldsToSerialize.cs | 67 ++--- .../Serialization/IFieldsToSerialize.cs | 8 +- .../Acceptance/InjectableResourceTests.cs | 2 +- .../CompositeKeys/CompositeKeyTests.cs | 2 +- .../EagerLoading/EagerLoadingTests.cs | 8 +- .../IdObfuscation/IdObfuscationTests.cs | 3 +- .../PaginationWithTotalCountTests.cs | 2 +- .../ReadWrite/Creating/CreateResourceTests.cs | 7 +- ...reateResourceWithClientGeneratedIdTests.cs | 6 +- ...eateResourceWithToManyRelationshipTests.cs | 18 +- ...reateResourceWithToOneRelationshipTests.cs | 12 +- .../Fetching/FetchRelationshipTests.cs | 5 + .../ReadWrite/Fetching/FetchResourceTests.cs | 14 +- .../ReplaceToManyRelationshipTests.cs | 8 +- .../Updating/Resources/UpdateResourceTests.cs | 13 +- .../Resources/UpdateToOneRelationshipTests.cs | 7 +- .../ResourceDefinitionQueryCallbackTests.cs | 6 +- .../SparseFieldSets/SparseFieldSetTests.cs | 243 +++++++++++++++--- .../SparseFieldSetParseTests.cs | 44 ++-- .../Serialization/SerializerTestsSetup.cs | 4 +- .../IncludedResourceObjectBuilderTests.cs | 3 +- ...ADNC_GettingStarted_PostmanCollection.json | 4 +- 45 files changed, 804 insertions(+), 277 deletions(-) create mode 100644 src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs create mode 100644 src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs index b5be39eb03..e4d639727f 100644 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ b/benchmarks/Query/QueryParserBenchmarks.cs @@ -31,7 +31,8 @@ public QueryParserBenchmarks() var request = new JsonApiRequest { - PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)) + PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)), + IsCollection = true }; _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor); @@ -56,6 +57,7 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr { var resourceFactory = new ResourceFactory(new ServiceContainer()); + var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options); var sortReader = new SortQueryStringParameterReader(request, resourceGraph); var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); @@ -65,7 +67,7 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr var readers = new List { - filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader + includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader }; return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); @@ -92,7 +94,7 @@ public void DescendingSort() [Benchmark] public void ComplexQuery() => Run(100, () => { - var queryString = $"?filter[{BenchmarkResourcePublicNames.NameAttr}]=abc,eq:abc&sort=-{BenchmarkResourcePublicNames.NameAttr}&include=child&page[size]=1&fields={BenchmarkResourcePublicNames.NameAttr}"; + var queryString = $"?filter[{BenchmarkResourcePublicNames.NameAttr}]=abc,eq:abc&sort=-{BenchmarkResourcePublicNames.NameAttr}&include=child&page[size]=1&fields[{BenchmarkResourcePublicNames.Type}]={BenchmarkResourcePublicNames.NameAttr}"; _queryStringAccessor.SetQueryString(queryString); _queryStringReaderForAll.ReadAll(null); diff --git a/docs/internals/queries.md b/docs/internals/queries.md index f87e818072..9268ff24dc 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -34,13 +34,13 @@ To get a sense of what this all looks like, let's look at an example query strin filter=has(articles)& sort=count(articles)& page[number]=3& - fields=title& + fields[blogs]=title& filter[articles]=and(not(equals(author.firstName,null)),has(revisions))& sort[articles]=author.lastName& fields[articles]=url& filter[articles.revisions]=and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J'))& sort[articles.revisions]=-publishTime,author.lastName& - fields[articles.revisions]=publishTime + fields[revisions]=publishTime ``` After parsing, the set of scoped expressions is transformed into the following tree by `QueryLayerComposer`: diff --git a/docs/request-examples/004_GET_Books-PublishYear.ps1 b/docs/request-examples/004_GET_Books-PublishYear.ps1 index bba11d4060..a08cb7e6a0 100644 --- a/docs/request-examples/004_GET_Books-PublishYear.ps1 +++ b/docs/request-examples/004_GET_Books-PublishYear.ps1 @@ -1 +1 @@ -curl -s -f http://localhost:14141/api/books?fields=publishYear +curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md index 9e7b654136..8a84001b88 100644 --- a/docs/usage/reading/sparse-fieldset-selection.md +++ b/docs/usage/reading/sparse-fieldset-selection.md @@ -1,21 +1,24 @@ # Sparse Fieldset Selection -As an alternative to returning all attributes from a resource, the `fields` query string parameter can be used to select only a subset. -This can be used on the resource being requested, as well as nested endpoints and/or included resources. +As an alternative to returning all fields (attributes and relationships) from a resource, the `fields[]` query string parameter can be used to select a subset. +Put the resource type to apply the fieldset on between the brackets. +This can be used on the resource being requested, as well as on nested endpoints and/or included resources. Top-level example: ```http -GET /articles?fields=title,body HTTP/1.1 +GET /articles?fields[articles]=title,body,comments HTTP/1.1 ``` Nested endpoint example: ```http -GET /api/blogs/1/articles?fields=title,body HTTP/1.1 +GET /api/blogs/1/articles?fields[articles]=title,body,comments HTTP/1.1 ``` +When combined with the `include` query string parameter, a subset of related fields can be specified too. + Example for an included HasOne relationship: ```http -GET /articles?include=author&fields[author]=name HTTP/1.1 +GET /articles?include=author&fields[authors]=name HTTP/1.1 ``` Example for an included HasMany relationship: @@ -25,9 +28,12 @@ GET /articles?include=revisions&fields[revisions]=publishTime HTTP/1.1 Example for both top-level and relationship: ```http -GET /articles?include=author&fields=title,body&fields[author]=name HTTP/1.1 +GET /articles?include=author&fields[articles]=title,body,author&fields[authors]=name HTTP/1.1 ``` +Note that in the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned. +When omitted, you'll get the included resources returned, but without full resource linkage (as described [here](https://jsonapi.org/examples/#sparse-fieldsets)). + ## Overriding As a developer, you can force to include and/or exclude specific fields as [described previously](~/usage/resources/resource-definitions.md). diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index 70686c7734..d929aa6f49 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -39,7 +39,7 @@ This can be overridden per attribute. ### Viewability -Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields=`, it results in an HTTP 400 response. +Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. ```c# public class User : Identifiable diff --git a/docs/usage/resources/resource-definitions.md b/docs/usage/resources/resource-definitions.md index 7168ccb6b9..4846372057 100644 --- a/docs/usage/resources/resource-definitions.md +++ b/docs/usage/resources/resource-definitions.md @@ -18,7 +18,7 @@ from Entity Framework Core `IQueryable` execution. ### Excluding fields -There are some cases where you want attributes conditionally excluded from your resource response. +There are some cases where you want attributes (or relationships) conditionally excluded from your resource response. For example, you may accept some sensitive data that should only be exposed to administrators after creation. Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]`. diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md index 7cda3dd61e..4cbe42602e 100644 --- a/docs/usage/writing/creating.md +++ b/docs/usage/writing/creating.md @@ -61,10 +61,10 @@ POST /articles HTTP/1.1 # Response body -POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: +POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields[]`. For example: ```http -POST /articles?include=owner&fields[owner]=firstName HTTP/1.1 +POST /articles?include=owner&fields[people]=firstName HTTP/1.1 { ... diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md index 447a29550a..132d487cfe 100644 --- a/docs/usage/writing/updating.md +++ b/docs/usage/writing/updating.md @@ -69,10 +69,10 @@ By combining the examples above, both attributes and relationships can be update ## Response body -PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example: +PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields[]`. For example: ```http -PATCH /articles/1?include=owner&fields[owner]=firstName HTTP/1.1 +PATCH /articles/1?include=owner&fields[people]=firstName HTTP/1.1 { ... diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 0ab164810c..16daf865f5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index 82d433324e..cc91a119e3 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 5d2babea1e..8a09340a11 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Configuration; namespace JsonApiDotNetCore.Queries.Expressions { @@ -191,6 +192,30 @@ public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expressio return null; } + public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + { + if (expression != null) + { + var newTable = new Dictionary(); + + foreach (var (resourceContext, sparseFieldSet) in expression.Table) + { + if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) + { + newTable[resourceContext] = newSparseFieldSet; + } + } + + if (newTable.Count > 0) + { + var newExpression = new SparseFieldTableExpression(newTable); + return newExpression.Equals(expression) ? expression : newExpression; + } + } + + return null; + } + public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) { return expression; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index cfe9ad5c73..309da19819 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -80,6 +80,11 @@ public virtual TResult VisitEqualsAnyOf(EqualsAnyOfExpression expression, TArgum return DefaultVisit(expression, argument); } + public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) { return DefaultVisit(expression, argument); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index e9bb37b014..9e8a930703 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -6,19 +6,19 @@ namespace JsonApiDotNetCore.Queries.Expressions { /// - /// Represents a sparse fieldset, resulting from text such as: firstName,lastName + /// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles /// public class SparseFieldSetExpression : QueryExpression { - public IReadOnlyCollection Attributes { get; } + public IReadOnlyCollection Fields { get; } - public SparseFieldSetExpression(IReadOnlyCollection attributes) + public SparseFieldSetExpression(IReadOnlyCollection fields) { - Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes)); + Fields = fields ?? throw new ArgumentNullException(nameof(fields)); - if (!attributes.Any()) + if (!fields.Any()) { - throw new ArgumentException("Must have one or more attributes.", nameof(attributes)); + throw new ArgumentException("Must have one or more fields.", nameof(fields)); } } @@ -29,7 +29,7 @@ public override TResult Accept(QueryExpressionVisitor child.PublicName)); + return string.Join(",", Fields.Select(child => child.PublicName)); } public override bool Equals(object obj) @@ -46,16 +46,16 @@ public override bool Equals(object obj) var other = (SparseFieldSetExpression) obj; - return Attributes.SequenceEqual(other.Attributes); + return Fields.SequenceEqual(other.Fields); } public override int GetHashCode() { var hashCode = new HashCode(); - foreach (var attribute in Attributes) + foreach (var field in Fields) { - hashCode.Add(attribute); + hashCode.Add(field); } return hashCode.ToHashCode(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 6d3eaecb20..79b4b12781 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCore.Queries.Expressions public static class SparseFieldSetExpressionExtensions { public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, - Expression> attributeSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { - if (attributeSelector == null) + if (fieldSelector == null) { - throw new ArgumentNullException(nameof(attributeSelector)); + throw new ArgumentNullException(nameof(fieldSelector)); } if (resourceGraph == null) @@ -23,33 +23,33 @@ public static SparseFieldSetExpression Including(this SparseFieldSetE throw new ArgumentNullException(nameof(resourceGraph)); } - foreach (var attribute in resourceGraph.GetAttributes(attributeSelector)) + foreach (var field in resourceGraph.GetFields(fieldSelector)) { - sparseFieldSet = IncludeAttribute(sparseFieldSet, attribute); + sparseFieldSet = IncludeField(sparseFieldSet, field); } return sparseFieldSet; } - private static SparseFieldSetExpression IncludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToInclude) + private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToInclude) { - if (sparseFieldSet == null || sparseFieldSet.Attributes.Contains(attributeToInclude)) + if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) { return sparseFieldSet; } - var attributeSet = sparseFieldSet.Attributes.ToHashSet(); - attributeSet.Add(attributeToInclude); - return new SparseFieldSetExpression(attributeSet); + var fieldSet = sparseFieldSet.Fields.ToHashSet(); + fieldSet.Add(fieldToInclude); + return new SparseFieldSetExpression(fieldSet); } public static SparseFieldSetExpression Excluding(this SparseFieldSetExpression sparseFieldSet, - Expression> attributeSelector, IResourceGraph resourceGraph) + Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { - if (attributeSelector == null) + if (fieldSelector == null) { - throw new ArgumentNullException(nameof(attributeSelector)); + throw new ArgumentNullException(nameof(fieldSelector)); } if (resourceGraph == null) @@ -57,29 +57,29 @@ public static SparseFieldSetExpression Excluding(this SparseFieldSetE throw new ArgumentNullException(nameof(resourceGraph)); } - foreach (var attribute in resourceGraph.GetAttributes(attributeSelector)) + foreach (var field in resourceGraph.GetFields(fieldSelector)) { - sparseFieldSet = ExcludeAttribute(sparseFieldSet, attribute); + sparseFieldSet = ExcludeField(sparseFieldSet, field); } return sparseFieldSet; } - private static SparseFieldSetExpression ExcludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToExclude) + private static SparseFieldSetExpression ExcludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToExclude) { - // Design tradeoff: When the sparse fieldset is empty, it means all attributes will be selected. - // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded attribute from data store. - // But later, when serializing the response, the sparse fieldset is first populated with all attributes, - // so then the exclusion will actually be applied and the excluded attribute is not returned to the client. + // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. + // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. + // But later, when serializing the response, the sparse fieldset is first populated with all fields, + // so then the exclusion will actually be applied and the excluded field is not returned to the client. - if (sparseFieldSet == null || !sparseFieldSet.Attributes.Contains(attributeToExclude)) + if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) { return sparseFieldSet; } - var attributeSet = sparseFieldSet.Attributes.ToHashSet(); - attributeSet.Remove(attributeToExclude); - return new SparseFieldSetExpression(attributeSet); + var fieldSet = sparseFieldSet.Fields.ToHashSet(); + fieldSet.Remove(fieldToExclude); + return new SparseFieldSetExpression(fieldSet); } } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs new file mode 100644 index 0000000000..468e66a998 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Expressions +{ + /// + /// Represents a lookup table of sparse fieldsets per resource type. + /// + public class SparseFieldTableExpression : QueryExpression + { + public IReadOnlyDictionary Table { get; } + + public SparseFieldTableExpression(IReadOnlyDictionary table) + { + Table = table ?? throw new ArgumentNullException(nameof(table)); + + if (!table.Any()) + { + throw new ArgumentException("Must have one or more entries.", nameof(table)); + } + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldTable(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + foreach (var (resource, fields) in Table) + { + if (builder.Length > 0) + { + builder.Append(","); + } + + builder.Append(resource.PublicName); + builder.Append("("); + builder.Append(fields); + 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 = (SparseFieldTableExpression) obj; + + return Table.SequenceEqual(other.Table); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + foreach (var (resourceContext, sparseFieldSet) in Table) + { + hashCode.Add(resourceContext); + hashCode.Add(sparseFieldSet); + } + + return hashCode.ToHashCode(); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index 863cc26b8c..4dafa05c1f 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -232,7 +232,7 @@ public RelationshipAttribute GetToOneRelationship(string publicName, ResourceCon return relationship; } - public AttrAttribute GetAttribute(string publicName, ResourceContext resourceContext, string path) + private AttrAttribute GetAttribute(string publicName, ResourceContext resourceContext, string path) { var attribute = resourceContext.Attributes.FirstOrDefault(a => a.PublicName == publicName); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs index e2e205ebf8..25d2a21e71 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -9,18 +9,18 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing { public class SparseFieldSetParser : QueryExpressionParser { - private readonly Action _validateSingleAttributeCallback; - private ResourceContext _resourceContextInScope; + private readonly Action _validateSingleFieldCallback; + private ResourceContext _resourceContext; - public SparseFieldSetParser(IResourceContextProvider resourceContextProvider, Action validateSingleAttributeCallback = null) + public SparseFieldSetParser(IResourceContextProvider resourceContextProvider, Action validateSingleFieldCallback = null) : base(resourceContextProvider) { - _validateSingleAttributeCallback = validateSingleAttributeCallback; + _validateSingleFieldCallback = validateSingleFieldCallback; } - public SparseFieldSetExpression Parse(string source, ResourceContext resourceContextInScope) + public SparseFieldSetExpression Parse(string source, ResourceContext resourceContext) { - _resourceContextInScope = resourceContextInScope ?? throw new ArgumentNullException(nameof(resourceContextInScope)); + _resourceContext = resourceContext ?? throw new ArgumentNullException(nameof(resourceContext)); Tokenize(source); var expression = ParseSparseFieldSet(); @@ -32,31 +32,31 @@ public SparseFieldSetExpression Parse(string source, ResourceContext resourceCon protected SparseFieldSetExpression ParseSparseFieldSet() { - var attributes = new Dictionary(); + var fields = new Dictionary(); - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Attribute name expected."); - AttrAttribute nextAttribute = nextChain.Fields.Cast().Single(); - attributes[nextAttribute.PublicName] = nextAttribute; + ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); + ResourceFieldAttribute nextField = nextChain.Fields.Single(); + fields[nextField.PublicName] = nextField; while (TokenStack.Any()) { EatSingleCharacterToken(TokenKind.Comma); - nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Attribute name expected."); - nextAttribute = nextChain.Fields.Cast().Single(); - attributes[nextAttribute.PublicName] = nextAttribute; + nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); + nextField = nextChain.Fields.Single(); + fields[nextField.PublicName] = nextField; } - return new SparseFieldSetExpression(attributes.Values); + return new SparseFieldSetExpression(fields.Values); } protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - var attribute = ChainResolver.GetAttribute(path, _resourceContextInScope, path); + var field = ChainResolver.GetField(path, _resourceContext, path); - _validateSingleAttributeCallback?.Invoke(attribute, _resourceContextInScope, path); + _validateSingleFieldCallback?.Invoke(field, _resourceContext, path); - return new[] {attribute}; + return new[] {field}; } } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs new file mode 100644 index 0000000000..f1fd91b18b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal.Parsing +{ + public class SparseFieldTypeParser : QueryExpressionParser + { + private readonly IResourceContextProvider _resourceContextProvider; + + public SparseFieldTypeParser(IResourceContextProvider resourceContextProvider) + : base(resourceContextProvider) + { + _resourceContextProvider = resourceContextProvider; + } + + public ResourceContext Parse(string source) + { + Tokenize(source); + + var expression = ParseSparseFieldTarget(); + + AssertTokenStackIsEmpty(); + + return expression; + } + + private ResourceContext ParseSparseFieldTarget() + { + if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected."); + } + + EatSingleCharacterToken(TokenKind.OpenBracket); + + var resourceContext = ParseResourceName(); + + EatSingleCharacterToken(TokenKind.CloseBracket); + + return resourceContext; + } + + private ResourceContext ParseResourceName() + { + if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) + { + return GetResourceContext(token.Value); + } + + throw new QueryParseException("Resource type expected."); + } + + private ResourceContext GetResourceContext(string resourceName) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); + if (resourceContext == null) + { + throw new QueryParseException($"Resource type '{resourceName}' does not exist."); + } + + return resourceContext; + } + + protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index ffac2b4fd0..e7db3f8646 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -17,6 +17,7 @@ public class QueryLayerComposer : IQueryLayerComposer private readonly IJsonApiOptions _options; private readonly IPaginationContext _paginationContext; private readonly ITargetedFields _targetedFields; + private readonly SparseFieldSetCache _sparseFieldSetCache; public QueryLayerComposer( IEnumerable constraintProviders, @@ -32,6 +33,7 @@ public QueryLayerComposer( _options = options ?? throw new ArgumentNullException(nameof(options)); _paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); } /// @@ -80,7 +82,7 @@ private QueryLayer ComposeTopLayer(IEnumerable constraints, R Filter = GetFilter(expressionsInTopScope, resourceContext), Sort = GetSort(expressionsInTopScope, resourceContext), Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, - Projection = GetSparseFieldSetProjection(expressionsInTopScope, resourceContext) + Projection = GetProjectionForSparseAttributeSet(resourceContext) }; } @@ -134,7 +136,7 @@ private IReadOnlyCollection ProcessIncludeSet(IReadOnl Pagination = ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, resourceContext), - Projection = GetSparseFieldSetProjection(expressionsInCurrentScope, resourceContext) + Projection = GetProjectionForSparseAttributeSet(resourceContext) }; parentLayer.Projection.Add(includeElement.Relationship, child); @@ -189,7 +191,7 @@ public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext } else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) { - // Discard any top-level ?fields= or attribute exclusions from resource definition, because we need the full database row. + // 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)); @@ -214,9 +216,8 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondary private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) { var secondaryIdAttribute = GetIdAttribute(secondaryResourceContext); - var sparseFieldSet = new SparseFieldSetExpression(new[] {secondaryIdAttribute}); - var secondaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, secondaryResourceContext) ?? new Dictionary(); + var secondaryProjection = GetProjectionForSparseAttributeSet(secondaryResourceContext) ?? new Dictionary(); secondaryProjection[secondaryIdAttribute] = null; return secondaryProjection; @@ -233,9 +234,8 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, secondaryLayer.Include = null; var primaryIdAttribute = GetIdAttribute(primaryResourceContext); - var sparseFieldSet = new SparseFieldSetExpression(new[] {primaryIdAttribute}); - var primaryProjection = GetSparseFieldSetProjection(new[] {sparseFieldSet}, primaryResourceContext) ?? new Dictionary(); + var primaryProjection = GetProjectionForSparseAttributeSet(primaryResourceContext) ?? new Dictionary(); primaryProjection[secondaryRelationship] = secondaryLayer; primaryProjection[primaryIdAttribute] = null; @@ -426,27 +426,21 @@ protected virtual PaginationExpression GetPagination(IReadOnlyCollection GetSparseFieldSetProjection(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) + protected virtual IDictionary GetProjectionForSparseAttributeSet(ResourceContext resourceContext) { - if (expressionsInScope == null) throw new ArgumentNullException(nameof(expressionsInScope)); if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); - var attributes = expressionsInScope.OfType().SelectMany(sparseFieldSet => sparseFieldSet.Attributes).ToHashSet(); - - var tempExpression = attributes.Any() ? new SparseFieldSetExpression(attributes) : null; - tempExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, tempExpression); - - attributes = tempExpression == null ? new HashSet() : tempExpression.Attributes.ToHashSet(); - - if (!attributes.Any()) + var fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceContext); + if (!fieldSet.Any()) { return null; } + var attributeSet = fieldSet.OfType().ToHashSet(); var idAttribute = GetIdAttribute(resourceContext); - attributes.Add(idAttribute); + attributeSet.Add(idAttribute); - return attributes.Cast().ToDictionary(key => key, value => (QueryLayer)null); + return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, value => (QueryLayer)null); } private static AttrAttribute GetIdAttribute(ResourceContext resourceContext) diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs new file mode 100644 index 0000000000..47bbf54b96 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Internal +{ + /// + /// Takes sparse fieldsets from s and invokes on them. + /// + public sealed class SparseFieldSetCache + { + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly Lazy>> _lazySourceTable; + private readonly IDictionary> _visitedTable; + + public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + if (constraintProviders == null) throw new ArgumentNullException(nameof(constraintProviders)); + _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + + _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); + _visitedTable = new Dictionary>(); + } + + private static IDictionary> BuildSourceTable(IEnumerable constraintProviders) + { + var sparseFieldTables = constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .Select(expression => expression.Table) + .ToArray(); + + var mergedTable = new Dictionary>(); + + foreach (var sparseFieldTable in sparseFieldTables) + { + foreach (var (resourceContext, sparseFieldSet) in sparseFieldTable) + { + if (!mergedTable.ContainsKey(resourceContext)) + { + mergedTable[resourceContext] = new HashSet(); + } + + foreach (var field in sparseFieldSet.Fields) + { + mergedTable[resourceContext].Add(field); + } + } + } + + return mergedTable; + } + + public IReadOnlyCollection GetSparseFieldSetForQuery(ResourceContext resourceContext) + { + if (!_visitedTable.ContainsKey(resourceContext)) + { + var inputExpression = _lazySourceTable.Value.ContainsKey(resourceContext) + ? new SparseFieldSetExpression(_lazySourceTable.Value[resourceContext]) + : null; + + var outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + + var outputFields = outputExpression == null + ? new HashSet() + : outputExpression.Fields.ToHashSet(); + + _visitedTable[resourceContext] = outputFields; + } + + return _visitedTable[resourceContext]; + } + + public IReadOnlyCollection GetSparseFieldSetForSerializer(ResourceContext resourceContext) + { + if (!_visitedTable.ContainsKey(resourceContext)) + { + var inputFields = _lazySourceTable.Value.ContainsKey(resourceContext) + ? _lazySourceTable.Value[resourceContext] + : GetResourceFields(resourceContext); + + var inputExpression = new SparseFieldSetExpression(inputFields); + var outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + + HashSet outputFields; + if (outputExpression == null) + { + outputFields = GetResourceFields(resourceContext); + } + else + { + outputFields = new HashSet(inputFields); + outputFields.IntersectWith(outputExpression.Fields); + } + + _visitedTable[resourceContext] = outputFields; + } + + return _visitedTable[resourceContext]; + } + + private HashSet GetResourceFields(ResourceContext resourceContext) + { + var fieldSet = new HashSet(); + + foreach (var attribute in resourceContext.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) + { + fieldSet.Add(attribute); + } + + foreach (var relationship in resourceContext.Relationships) + { + fieldSet.Add(relationship); + } + + return fieldSet; + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 686c890d3c..973cb409f2 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Errors; @@ -14,21 +15,21 @@ namespace JsonApiDotNetCore.QueryStrings.Internal { public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader { - private readonly QueryStringParameterScopeParser _scopeParser; + private readonly SparseFieldTypeParser _sparseFieldTypeParser; private readonly SparseFieldSetParser _sparseFieldSetParser; - private readonly List _constraints = new List(); + private readonly Dictionary _sparseFieldTable = new Dictionary(); private string _lastParameterName; public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) : base(request, resourceContextProvider) { - _sparseFieldSetParser = new SparseFieldSetParser(resourceContextProvider, ValidateSingleAttribute); - _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.IsRelationship); + _sparseFieldTypeParser = new SparseFieldTypeParser(resourceContextProvider); + _sparseFieldSetParser = new SparseFieldSetParser(resourceContextProvider, ValidateSingleField); } - protected void ValidateSingleAttribute(AttrAttribute attribute, ResourceContext resourceContext, string path) + protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, string path) { - if (!attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) { throw new InvalidQueryStringParameterException(_lastParameterName, "Retrieving the requested attribute is not allowed.", $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); @@ -46,8 +47,7 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr /// public virtual bool CanRead(string parameterName) { - var isNested = parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - return parameterName == "fields" || isNested; + return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); } /// @@ -57,11 +57,10 @@ public virtual void Read(string parameterName, StringValues parameterValue) try { - ResourceFieldChainExpression scope = GetScope(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, scope); + var targetResource = GetSparseFieldType(parameterName); + var sparseFieldSet = GetSparseFieldSet(parameterValue, targetResource); - var expressionInScope = new ExpressionInScope(scope, sparseFieldSet); - _constraints.Add(expressionInScope); + _sparseFieldTable[targetResource] = sparseFieldSet; } catch (QueryParseException exception) { @@ -70,22 +69,25 @@ public virtual void Read(string parameterName, StringValues parameterValue) } } - private ResourceFieldChainExpression GetScope(string parameterName) + private ResourceContext GetSparseFieldType(string parameterName) { - var parameterScope = _scopeParser.Parse(parameterName, RequestResource); - return parameterScope.Scope; + return _sparseFieldTypeParser.Parse(parameterName); } - private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceFieldChainExpression scope) + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceContext resourceContext) { - ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - return _sparseFieldSetParser.Parse(parameterValue, resourceContextInScope); + return _sparseFieldSetParser.Parse(parameterValue, resourceContext); } /// public virtual IReadOnlyCollection GetConstraints() { - return _constraints; + return _sparseFieldTable.Any() + ? new[] + { + new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTable)) + } + : Array.Empty(); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 38468d6f75..2697eb63a2 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -15,10 +17,12 @@ public class IncludedResourceObjectBuilder : ResourceObjectBuilder, IIncludedRes private readonly IFieldsToSerialize _fieldsToSerialize; private readonly ILinkBuilder _linkBuilder; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly SparseFieldSetCache _sparseFieldSetCache; public IncludedResourceObjectBuilder(IFieldsToSerialize fieldsToSerialize, ILinkBuilder linkBuilder, IResourceContextProvider resourceContextProvider, + IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectBuilderSettingsProvider settingsProvider) : base(resourceContextProvider, settingsProvider.Get()) @@ -27,6 +31,7 @@ public IncludedResourceObjectBuilder(IFieldsToSerialize fieldsToSerialize, _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); } /// @@ -39,6 +44,17 @@ public IList Build() { if (resourceObject.Relationships != null) { + foreach (var relationshipName in resourceObject.Relationships.Keys.ToArray()) + { + var resourceContext = ResourceContextProvider.GetResourceContext(resourceObject.Type); + var relationship = resourceContext.Relationships.Single(rel => rel.PublicName == relationshipName); + + if (!IsRelationshipInSparseFieldSet(relationship)) + { + resourceObject.Relationships.Remove(relationshipName); + } + } + // removes relationship entries (s) if they're completely empty. var pruned = resourceObject.Relationships.Where(p => p.Value.IsPopulated || p.Value.Links != null).ToDictionary(p => p.Key, p => p.Value); if (!pruned.Any()) pruned = null; @@ -51,6 +67,14 @@ public IList Build() return null; } + private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) + { + var resourceContext = ResourceContextProvider.GetResourceContext(relationship.LeftType); + + var fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); + return fieldSet.Contains(relationship); + } + /// public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, IReadOnlyCollection relationships = null) @@ -74,22 +98,22 @@ public void IncludeRelationshipChain(IReadOnlyCollection var relationship = inclusionChain.First(); var chainRemainder = ShiftChain(inclusionChain); var related = relationship.GetValue(rootResource); - ProcessChain(relationship, related, chainRemainder); + ProcessChain(related, chainRemainder); } - private void ProcessChain(RelationshipAttribute originRelationship, object related, List inclusionChain) + private void ProcessChain(object related, List inclusionChain) { if (related is IEnumerable children) foreach (IIdentifiable child in children) - ProcessRelationship(originRelationship, child, inclusionChain); + ProcessRelationship(child, inclusionChain); else - ProcessRelationship(originRelationship, (IIdentifiable)related, inclusionChain); + ProcessRelationship((IIdentifiable)related, inclusionChain); } - private void ProcessRelationship(RelationshipAttribute originRelationship, IIdentifiable parent, List inclusionChain) + private void ProcessRelationship(IIdentifiable parent, List inclusionChain) { // get the resource object for parent. - var resourceObject = GetOrBuildResourceObject(parent, originRelationship); + var resourceObject = GetOrBuildResourceObject(parent); if (!inclusionChain.Any()) return; var nextRelationship = inclusionChain.First(); @@ -108,7 +132,7 @@ private void ProcessRelationship(RelationshipAttribute originRelationship, IIden { // if the relationship is set, continue parsing the chain. var related = nextRelationship.GetValue(parent); - ProcessChain(nextRelationship, related, chainRemainder); + ProcessChain(related, chainRemainder); } } @@ -135,14 +159,14 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r /// Gets the resource object for by searching the included list. /// If it was not already built, it is constructed and added to the inclusion list. /// - private ResourceObject GetOrBuildResourceObject(IIdentifiable parent, RelationshipAttribute relationship) + private ResourceObject GetOrBuildResourceObject(IIdentifiable parent) { var type = parent.GetType(); var resourceName = ResourceContextProvider.GetResourceContext(type).PublicName; var entry = _included.SingleOrDefault(ro => ro.Type == resourceName && ro.Id == parent.StringId); if (entry == null) { - entry = Build(parent, _fieldsToSerialize.GetAttributes(type, relationship), _fieldsToSerialize.GetRelationships(type)); + entry = Build(parent, _fieldsToSerialize.GetAttributes(type), _fieldsToSerialize.GetRelationships(type)); _included.Add(entry); } return entry; diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs index 6daae945ec..a0e084a4cf 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -17,6 +18,7 @@ public class ResponseResourceObjectBuilder : ResourceObjectBuilder private readonly IEnumerable _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly ILinkBuilder _linkBuilder; + private readonly SparseFieldSetCache _sparseFieldSetCache; private RelationshipAttribute _requestRelationship; public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, @@ -31,6 +33,7 @@ public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, _includedBuilder = includedBuilder ?? throw new ArgumentNullException(nameof(includedBuilder)); _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); } public RelationshipEntry Build(IIdentifiable resource, RelationshipAttribute requestRelationship) @@ -75,16 +78,32 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r _includedBuilder.IncludeRelationshipChain(chain, resource); } + if (!IsRelationshipInSparseFieldSet(relationship)) + { + return null; + } + var links = _linkBuilder.GetRelationshipLinks(relationship, resource); if (links != null) - // if links relationshipLinks should be built for this entry, populate the "links" field. - (relationshipEntry ??= new RelationshipEntry()).Links = links; + { + // if relationshipLinks should be built for this entry, populate the "links" field. + relationshipEntry ??= new RelationshipEntry(); + relationshipEntry.Links = links; + } // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. // (see the NullValueHandling settings on ) return relationshipEntry; } + private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) + { + var resourceContext = ResourceContextProvider.GetResourceContext(relationship.LeftType); + + var fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); + return fieldSet.Contains(relationship); + } + /// /// Inspects the included relationship chains (see /// to see if should be included or not. diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index 721c15ac92..5d51eb24a6 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -13,25 +13,23 @@ namespace JsonApiDotNetCore.Serialization /// public class FieldsToSerialize : IFieldsToSerialize { - private readonly IResourceGraph _resourceGraph; - private readonly IEnumerable _constraintProviders; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiRequest _request; + private readonly SparseFieldSetCache _sparseFieldSetCache; public FieldsToSerialize( - IResourceGraph resourceGraph, + IResourceContextProvider resourceContextProvider, IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) { - _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); - _constraintProviders = constraintProviders ?? throw new ArgumentNullException(nameof(constraintProviders)); - _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _request = request ?? throw new ArgumentNullException(nameof(request)); + _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); } /// - public IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null) + public IReadOnlyCollection GetAttributes(Type resourceType) { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); @@ -40,41 +38,10 @@ public IReadOnlyCollection GetAttributes(Type resourceType, Relat return Array.Empty(); } - var sparseFieldSetAttributes = _constraintProviders - .SelectMany(p => p.GetConstraints()) - .Where(expressionInScope => relationship == null - ? expressionInScope.Scope == null - : expressionInScope.Scope != null && expressionInScope.Scope.Fields.Last().Equals(relationship)) - .Select(expressionInScope => expressionInScope.Expression) - .OfType() - .SelectMany(sparseFieldSet => sparseFieldSet.Attributes) - .ToHashSet(); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + var fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - if (!sparseFieldSetAttributes.Any()) - { - sparseFieldSetAttributes = GetViewableAttributes(resourceType); - } - - var inputExpression = sparseFieldSetAttributes.Any() ? new SparseFieldSetExpression(sparseFieldSetAttributes) : null; - var outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - if (outputExpression == null) - { - sparseFieldSetAttributes = GetViewableAttributes(resourceType); - } - else - { - sparseFieldSetAttributes.IntersectWith(outputExpression.Attributes); - } - - return sparseFieldSetAttributes; - } - - private HashSet GetViewableAttributes(Type resourceType) - { - return _resourceGraph.GetAttributes(resourceType) - .Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)) - .ToHashSet(); + return fieldSet.OfType().ToArray(); } /// @@ -84,13 +51,17 @@ private HashSet GetViewableAttributes(Type resourceType) /// is not the same as not including. In the case of the latter, /// we may still want to add the relationship to expose the navigation link to the client. /// - public IReadOnlyCollection GetRelationships(Type type) + public IReadOnlyCollection GetRelationships(Type resourceType) { - if (type == null) throw new ArgumentNullException(nameof(type)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + if (_request.Kind == EndpointKind.Relationship) + { + return Array.Empty(); + } - return _request.Kind == EndpointKind.Relationship - ? Array.Empty() - : _resourceGraph.GetRelationships(type); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + return resourceContext.Relationships; } } } diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs index 0c41e74abd..4f687f920a 100644 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs @@ -13,14 +13,12 @@ public interface IFieldsToSerialize { /// /// Gets the collection of attributes that are to be serialized for resources of type . - /// If is non-null, it will consider the allowed collection of attributes - /// as an included relationship. /// - IReadOnlyCollection GetAttributes(Type resourceType, RelationshipAttribute relationship = null); + IReadOnlyCollection GetAttributes(Type resourceType); /// - /// Gets the collection of relationships that are to be serialized for resources of type . + /// Gets the collection of relationships that are to be serialized for resources of type . /// - IReadOnlyCollection GetRelationships(Type type); + IReadOnlyCollection GetRelationships(Type resourceType); } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index ce8d259d5f..6388be509f 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -153,7 +153,7 @@ public async Task Can_Get_Passports_With_Sparse_Fieldset() _context.Passports.AddRange(passports); await _context.SaveChangesAsync(); - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields=socialSecurityNumber&fields[person]=firstName"); + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields[passports]=socialSecurityNumber&fields[people]=firstName"); // Act var response = await _fixture.Client.SendAsync(request); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 8cded61238..b55f9ab1b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -133,7 +133,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = "/cars?fields=id"; + var route = "/cars?fields[cars]=id"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 9d9c28e0a2..c86e8f5bd8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -107,7 +106,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/streets/{street.StringId}?fields=windowTotalCount"; + var route = $"/streets/{street.StringId}?fields[streets]=windowTotalCount"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -119,6 +118,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(street.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["windowTotalCount"].Should().Be(3); + responseDocument.SingleData.Relationships.Should().BeNull(); } [Fact] @@ -181,7 +181,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/states/{state.StringId}/cities?include=streets&fields=name&fields[streets]=doorTotalCount,windowTotalCount"; + var route = $"/states/{state.StringId}/cities?include=streets&fields[cities]=name&fields[streets]=doorTotalCount,windowTotalCount"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -193,6 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(state.Cities[0].StringId); responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["name"].Should().Be(state.Cities[0].Name); + responseDocument.ManyData[0].Relationships.Should().BeNull(); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("streets"); @@ -200,6 +201,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["doorTotalCount"].Should().Be(2); responseDocument.Included[0].Attributes["windowTotalCount"].Should().Be(1); + responseDocument.Included[0].Relationships.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index 811f58a7a3..e6c9226cc4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -152,7 +152,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/bankAccounts/{bankAccount.StringId}?include=cards&fields[cards]=ownerName"; + var route = $"/bankAccounts/{bankAccount.StringId}?include=cards&fields[debitCards]=ownerName"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -166,6 +166,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(bankAccount.Cards[0].StringId); responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Relationships.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index abe96c7714..00801c4690 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -730,7 +730,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); var routePrefix = "/api/v1/todoItems?filter=equals(owner.lastName,'" + WebUtility.UrlEncode(person.LastName) + "')" + - $"&fields[owner]=firstName&include=owner&sort=ordinal&foo=bar,baz"; + "&fields[people]=firstName&include=owner&sort=ordinal&foo=bar,baz"; var route = routePrefix + $"&page[number]={pageNumber}"; // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 07e452d6ae..e39c2bf3c1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -94,7 +94,6 @@ public async Task Can_create_resource_with_int_ID() responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); responseDocument.SingleData.Attributes["dueAt"].Should().Be(newWorkItem.DueAt); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -143,7 +142,6 @@ public async Task Can_create_resource_with_long_ID() responseDocument.SingleData.Type.Should().Be("userAccounts"); responseDocument.SingleData.Attributes["firstName"].Should().Be(newUserAccount.FirstName); responseDocument.SingleData.Attributes["lastName"].Should().Be(newUserAccount.LastName); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newUserAccountId = long.Parse(responseDocument.SingleData.Id); @@ -190,7 +188,6 @@ public async Task Can_create_resource_with_guid_ID() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newGroupId = Guid.Parse(responseDocument.SingleData.Id); @@ -237,7 +234,6 @@ public async Task Can_create_resource_without_attributes_or_relationships() responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Attributes["description"].Should().BeNull(); responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -282,6 +278,7 @@ public async Task Can_create_resource_with_unknown_attribute() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -327,6 +324,8 @@ public async Task Can_create_resource_with_unknown_relationship() responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Type.Should().Be("workItems"); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index b5ffbec5d7..1cb18ee0d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -57,6 +57,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Type.Should().Be("workItemGroups"); responseDocument.SingleData.Id.Should().Be(newGroup.StringId); responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -90,7 +91,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ } }; - var route = "/workItemGroups?fields=name"; + var route = "/workItemGroups?fields[workItemGroups]=name"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -103,6 +104,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ responseDocument.SingleData.Id.Should().Be(newGroup.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["name"].Should().Be(newGroup.Name); + responseDocument.SingleData.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -176,7 +178,7 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ } }; - var route = "/rgbColors?fields=id"; + var route = "/rgbColors?fields[rgbColors]=id"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index f994978bba..e8d3a32dfa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -72,6 +72,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().BeNull(); @@ -137,6 +138,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(2); @@ -145,6 +147,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["lastName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Relationships.Count > 0); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -199,7 +202,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?include=subscribers&fields[subscribers]=firstName"; + var route = "/workItems?include=subscribers&fields[userAccounts]=firstName"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -208,6 +211,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(2); @@ -216,6 +220,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingUserAccounts[1].StringId); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["firstName"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Relationships == null); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -281,7 +286,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?fields=priority&include=tags&fields[tags]=text"; + var route = "/workItems?fields[workItems]=priority,tags&include=tags&fields[workTags]=text"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -292,8 +297,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["priority"].Should().Be(workItemToCreate.Priority.ToString("G")); - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(3); + responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingTags[0].StringId); + responseDocument.SingleData.Relationships["tags"].ManyData[1].Id.Should().Be(existingTags[1].StringId); + responseDocument.SingleData.Relationships["tags"].ManyData[2].Id.Should().Be(existingTags[2].StringId); responseDocument.Included.Should().HaveCount(3); responseDocument.Included.Should().OnlyContain(resource => resource.Type == "workTags"); @@ -302,6 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().ContainSingle(resource => resource.Id == existingTags[2].StringId); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes.Count == 1); responseDocument.Included.Should().OnlyContain(resource => resource.Attributes["text"] != null); + responseDocument.Included.Should().OnlyContain(resource => resource.Relationships == null); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -575,6 +584,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index 758f9c9341..01340909ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -66,6 +66,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); var newGroupId = responseDocument.SingleData.Id; @@ -187,6 +188,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); @@ -194,6 +196,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -245,7 +248,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/workItems?fields=description&include=assignee"; + var route = "/workItems?fields[workItems]=description,assignee&include=assignee"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -256,14 +259,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["description"].Should().Be(newWorkItem.Description); - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["assignee"].SingleData.Id.Should().Be(existingUserAccount.StringId); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); @@ -513,6 +517,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes.Should().NotBeEmpty(); responseDocument.SingleData.Relationships.Should().NotBeEmpty(); responseDocument.Included.Should().HaveCount(1); @@ -520,6 +525,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(existingUserAccounts[1].StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccounts[1].FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccounts[1].LastName); + responseDocument.Included[0].Relationships.Should().NotBeEmpty(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs index fa54ac50f7..14b8a6f490 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -43,6 +43,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Type.Should().Be("userAccounts"); responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); responseDocument.SingleData.Attributes.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); } [Fact] @@ -93,10 +94,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var item1 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(0).StringId); item1.Type.Should().Be("workItems"); item1.Attributes.Should().BeNull(); + item1.Relationships.Should().BeNull(); var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); item2.Type.Should().Be("workItems"); item2.Attributes.Should().BeNull(); + item2.Relationships.Should().BeNull(); } [Fact] @@ -158,10 +161,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var item1 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(0).Tag.StringId); item1.Type.Should().Be("workTags"); item1.Attributes.Should().BeNull(); + item1.Relationships.Should().BeNull(); var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); item2.Type.Should().Be("workTags"); item2.Attributes.Should().BeNull(); + item2.Relationships.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 905a9e5f14..5068c58be1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -47,12 +47,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => item1.Attributes["description"].Should().Be(workItems[0].Description); item1.Attributes["dueAt"].Should().BeCloseTo(workItems[0].DueAt); item1.Attributes["priority"].Should().Be(workItems[0].Priority.ToString("G")); - + item1.Relationships.Should().NotBeEmpty(); + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItems[1].StringId); item2.Type.Should().Be("workItems"); item2.Attributes["description"].Should().Be(workItems[1].Description); item2.Attributes["dueAt"].Should().BeCloseTo(workItems[1].DueAt); item2.Attributes["priority"].Should().Be(workItems[1].Priority.ToString("G")); + item2.Relationships.Should().NotBeEmpty(); } [Fact] @@ -96,6 +98,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["description"].Should().Be(workItem.Description); responseDocument.SingleData.Attributes["dueAt"].Should().BeCloseTo(workItem.DueAt); responseDocument.SingleData.Attributes["priority"].Should().Be(workItem.Priority.ToString("G")); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); } [Fact] @@ -157,6 +160,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(workItem.Assignee.StringId); responseDocument.SingleData.Attributes["firstName"].Should().Be(workItem.Assignee.FirstName); responseDocument.SingleData.Attributes["lastName"].Should().Be(workItem.Assignee.LastName); + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); } [Fact] @@ -210,12 +214,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => item1.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(0).Description); item1.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(0).DueAt); item1.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(0).Priority.ToString("G")); - + item1.Relationships.Should().NotBeEmpty(); + var item2 = responseDocument.ManyData.Single(resource => resource.Id == userAccount.AssignedItems.ElementAt(1).StringId); item2.Type.Should().Be("workItems"); item2.Attributes["description"].Should().Be(userAccount.AssignedItems.ElementAt(1).Description); item2.Attributes["dueAt"].Should().BeCloseTo(userAccount.AssignedItems.ElementAt(1).DueAt); item2.Attributes["priority"].Should().Be(userAccount.AssignedItems.ElementAt(1).Priority.ToString("G")); + item2.Relationships.Should().NotBeEmpty(); } [Fact] @@ -278,11 +284,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => item1.Type.Should().Be("workTags"); item1.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.Text); item1.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(0).Tag.IsBuiltIn); - + item1.Relationships.Should().BeNull(); + var item2 = responseDocument.ManyData.Single(resource => resource.Id == workItem.WorkItemTags.ElementAt(1).Tag.StringId); item2.Type.Should().Be("workTags"); item2.Attributes["text"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.Text); item2.Attributes["isBuiltIn"].Should().Be(workItem.WorkItemTags.ElementAt(1).Tag.IsBuiltIn); + item2.Relationships.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index bf183e57c0..3412c145fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -328,6 +328,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -376,7 +377,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}?fields=priority&include=tags&fields[tags]=text"; + var route = $"/workItems/{existingWorkItem.StringId}?fields[workItems]=priority,tags&include=tags&fields[workTags]=text"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -389,13 +390,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingTag.StringId); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("workTags"); responseDocument.Included[0].Id.Should().Be(existingTag.StringId); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["text"].Should().Be(existingTag.Text); + responseDocument.Included[0].Relationships.Should().BeNull(); var newWorkItemId = int.Parse(responseDocument.SingleData.Id); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 223ee17267..d7a08a4908 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -195,7 +195,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(existingGroup.StringId); responseDocument.SingleData.Attributes["name"].Should().Be(newName); responseDocument.SingleData.Attributes["isPublic"].Should().Be(existingGroup.IsPublic); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -348,7 +347,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes["dueAt"].Should().BeNull(); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); responseDocument.SingleData.Attributes.Should().ContainKey("concurrencyToken"); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -389,7 +387,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority"; + var route = $"/workItems/{existingWorkItem.StringId}?fields[workItems]=description,priority"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -403,8 +401,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -452,7 +449,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}?fields=description,priority&include=tags&fields[tags]=text"; + var route = $"/workItems/{existingWorkItem.StringId}?fields[workItems]=description,priority,tags&include=tags&fields[workTags]=text"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -466,8 +463,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["description"].Should().Be(newDescription); responseDocument.SingleData.Attributes["priority"].Should().Be(existingWorkItem.Priority.ToString("G")); - - responseDocument.SingleData.Relationships.Should().ContainKey("tags"); + responseDocument.SingleData.Relationships.Should().HaveCount(1); responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); @@ -476,6 +472,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(existingWorkItem.WorkItemTags.Single().Tag.StringId); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["text"].Should().Be(existingWorkItem.WorkItemTags.Single().Tag.Text); + responseDocument.Included[0].Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 8cf3f6bf11..f2b409aa71 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -352,6 +352,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(existingUserAccount.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -399,7 +400,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = $"/workItems/{existingWorkItem.StringId}?fields=description&include=assignee&fields[assignee]=lastName"; + var route = $"/workItems/{existingWorkItem.StringId}?fields[workItems]=description,assignee&include=assignee&fields[userAccounts]=lastName"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -412,13 +413,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(existingWorkItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["description"].Should().Be(existingWorkItem.Description); - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["assignee"].SingleData.Id.Should().Be(existingUserAccount.StringId); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("userAccounts"); responseDocument.Included[0].Id.Should().Be(existingUserAccount.StringId); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["lastName"].Should().Be(existingUserAccount.LastName); + responseDocument.Included[0].Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 1148497bff..8e6d6b2d8c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -338,7 +338,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/callableResources/{resource.StringId}?fields=label,status"; + var route = $"/callableResources/{resource.StringId}?fields[callableResources]=label,status"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -351,6 +351,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Attributes.Should().HaveCount(2); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); responseDocument.SingleData.Attributes["status"].Should().Be("5% completed."); + responseDocument.SingleData.Relationships.Should().BeNull(); } [Fact] @@ -401,7 +402,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/callableResources/{resource.StringId}?fields=label,riskLevel"; + var route = $"/callableResources/{resource.StringId}?fields[callableResources]=label,riskLevel"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -413,6 +414,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(resource.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["label"].Should().Be(resource.Label); + responseDocument.SingleData.Relationships.Should().BeNull(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 911e51a402..54f43380e0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -76,7 +76,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/articles?fields=caption"; + var route = "/api/v1/articles?fields[articles]=caption,author"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -88,12 +88,98 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(article.StringId); responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + responseDocument.ManyData[0].Relationships.Should().HaveCount(1); + responseDocument.ManyData[0].Relationships["author"].Data.Should().BeNull(); + responseDocument.ManyData[0].Relationships["author"].Links.Self.Should().NotBeNull(); + responseDocument.ManyData[0].Relationships["author"].Links.Related.Should().NotBeNull(); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Caption.Should().Be(article.Caption); articleCaptured.Url.Should().BeNull(); } + [Fact] + public async Task Can_select_attribute_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?fields[articles]=caption"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Caption.Should().Be(article.Caption); + articleCaptured.Url.Should().BeNull(); + } + + [Fact] + public async Task Can_select_relationship_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync
(); + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/v1/articles?fields[articles]=author"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(article.StringId); + responseDocument.ManyData[0].Attributes.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().HaveCount(1); + responseDocument.ManyData[0].Relationships["author"].Data.Should().BeNull(); + responseDocument.ManyData[0].Relationships["author"].Links.Self.Should().NotBeNull(); + responseDocument.ManyData[0].Relationships["author"].Links.Related.Should().NotBeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Caption.Should().BeNull(); + articleCaptured.Url.Should().BeNull(); + } + [Fact] public async Task Can_select_fields_in_primary_resource_by_ID() { @@ -114,7 +200,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/articles/{article.StringId}?fields=url"; + var route = $"/api/v1/articles/{article.StringId}?fields[articles]=url,author"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -126,6 +212,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(article.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["author"].Data.Should().BeNull(); + responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Url.Should().Be(article.Url); @@ -159,7 +249,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?fields=caption"; + var route = $"/api/v1/blogs/{blog.StringId}/articles?fields[articles]=caption,tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -171,6 +261,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(blog.Articles[0].StringId); responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(blog.Articles[0].Caption); + responseDocument.ManyData[0].Relationships.Should().HaveCount(1); + responseDocument.ManyData[0].Relationships["tags"].Data.Should().BeNull(); + responseDocument.ManyData[0].Relationships["tags"].Links.Self.Should().NotBeNull(); + responseDocument.ManyData[0].Relationships["tags"].Links.Related.Should().NotBeNull(); var blogCaptured = (Blog)store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -182,7 +276,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_fields_in_scope_of_HasOne_relationship() + public async Task Can_select_fields_of_HasOne_relationship() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); @@ -204,7 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/articles/{article.StringId}?include=author&fields[author]=lastName,businessEmail"; + var route = $"/api/v1/articles/{article.StringId}?include=author&fields[authors]=lastName,businessEmail,livingAddress"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -215,11 +309,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(article.StringId); responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + responseDocument.SingleData.Relationships["author"].SingleData.Id.Should().Be(article.Author.StringId); + responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["lastName"].Should().Be(article.Author.LastName); responseDocument.Included[0].Attributes["businessEmail"].Should().Be(article.Author.BusinessEmail); + responseDocument.Included[0].Relationships.Should().HaveCount(1); + responseDocument.Included[0].Relationships["livingAddress"].Data.Should().BeNull(); + responseDocument.Included[0].Relationships["livingAddress"].Links.Self.Should().NotBeNull(); + responseDocument.Included[0].Relationships["livingAddress"].Links.Related.Should().NotBeNull(); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -231,7 +332,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_fields_in_scope_of_HasMany_relationship() + public async Task Can_select_fields_of_HasMany_relationship() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); @@ -255,7 +356,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/authors/{author.StringId}?include=articles&fields[articles]=caption"; + var route = $"/api/v1/authors/{author.StringId}?include=articles&fields[articles]=caption,tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -266,10 +367,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(author.StringId); responseDocument.SingleData.Attributes["lastName"].Should().Be(author.LastName); + responseDocument.SingleData.Relationships["articles"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["articles"].ManyData[0].Id.Should().Be(author.Articles[0].StringId); + responseDocument.SingleData.Relationships["articles"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["articles"].Links.Related.Should().NotBeNull(); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(author.Articles[0].Caption); + responseDocument.Included[0].Relationships.Should().HaveCount(1); + responseDocument.Included[0].Relationships["tags"].Data.Should().BeNull(); + responseDocument.Included[0].Relationships["tags"].Links.Self.Should().NotBeNull(); + responseDocument.Included[0].Relationships["tags"].Links.Related.Should().NotBeNull(); var authorCaptured = (Author) store.Resources.Should().ContainSingle(x => x is Author).And.Subject.Single(); authorCaptured.Id.Should().Be(author.Id); @@ -281,7 +390,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_fields_in_scope_of_HasMany_relationship_on_secondary_resource() + public async Task Can_select_fields_of_HasMany_relationship_on_secondary_resource() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); @@ -310,7 +419,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&fields[articles]=caption"; + var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&fields[articles]=caption,revisions"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -321,10 +430,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(blog.Owner.StringId); responseDocument.SingleData.Attributes["lastName"].Should().Be(blog.Owner.LastName); + responseDocument.SingleData.Relationships["articles"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["articles"].ManyData[0].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.SingleData.Relationships["articles"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["articles"].Links.Related.Should().NotBeNull(); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + responseDocument.Included[0].Relationships.Should().HaveCount(1); + responseDocument.Included[0].Relationships["revisions"].Data.Should().BeNull(); + responseDocument.Included[0].Relationships["revisions"].Links.Self.Should().NotBeNull(); + responseDocument.Included[0].Relationships["revisions"].Links.Related.Should().NotBeNull(); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -337,7 +454,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_fields_in_scope_of_HasManyThrough_relationship() + public async Task Can_select_fields_of_HasManyThrough_relationship() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); @@ -374,10 +491,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(article.StringId); responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + responseDocument.SingleData.Relationships["tags"].ManyData.Should().HaveCount(1); + responseDocument.SingleData.Relationships["tags"].ManyData[0].Id.Should().Be(article.ArticleTags.ElementAt(0).Tag.StringId); + responseDocument.SingleData.Relationships["tags"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["tags"].Links.Related.Should().NotBeNull(); responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Attributes["color"].Should().Be(article.ArticleTags.Single().Tag.Color.ToString("G")); + responseDocument.Included[0].Relationships.Should().BeNull(); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -389,7 +511,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_select_fields_in_multiple_scopes() + public async Task Can_select_attributes_in_multiple_resource_types() { // Arrange var store = _testContext.Factory.Services.GetRequiredService(); @@ -422,7 +544,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields=title&fields[owner]=firstName,lastName&fields[owner.articles]=caption"; + var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title&fields[authors]=firstName,lastName&fields[articles]=caption"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -434,6 +556,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(blog.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); + responseDocument.SingleData.Relationships.Should().BeNull(); responseDocument.Included.Should().HaveCount(2); @@ -441,10 +564,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes.Should().HaveCount(2); responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); + responseDocument.Included[0].Relationships.Should().BeNull(); responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); responseDocument.Included[1].Attributes.Should().HaveCount(1); responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); + responseDocument.Included[1].Relationships.Should().BeNull(); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -494,7 +619,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields=title"; + var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title,owner"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -506,6 +631,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(blog.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["title"].Should().Be(blog.Title); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["owner"].SingleData.Id.Should().Be(blog.Owner.StringId); + responseDocument.SingleData.Relationships["owner"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["owner"].Links.Related.Should().NotBeNull(); responseDocument.Included.Should().HaveCount(2); @@ -513,10 +642,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); responseDocument.Included[0].Attributes["dateOfBirth"].Should().Be(blog.Owner.DateOfBirth); + responseDocument.Included[0].Relationships["articles"].ManyData.Should().HaveCount(1); + responseDocument.Included[0].Relationships["articles"].ManyData[0].Id.Should().Be(blog.Owner.Articles[0].StringId); + responseDocument.Included[0].Relationships["articles"].Links.Self.Should().NotBeNull(); + responseDocument.Included[0].Relationships["articles"].Links.Related.Should().NotBeNull(); responseDocument.Included[1].Id.Should().Be(blog.Owner.Articles[0].StringId); responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); responseDocument.Included[1].Attributes["url"].Should().Be(blog.Owner.Articles[0].Url); + responseDocument.Included[1].Relationships["tags"].Data.Should().BeNull(); + responseDocument.Included[1].Relationships["tags"].Links.Self.Should().NotBeNull(); + responseDocument.Included[1].Relationships["tags"].Links.Related.Should().NotBeNull(); var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); @@ -545,7 +681,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/articles?fields=id,caption"; + var route = "/api/v1/articles?fields[articles]=id,caption"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -557,6 +693,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Id.Should().Be(article.StringId); responseDocument.ManyData[0].Attributes.Should().HaveCount(1); responseDocument.ManyData[0].Attributes["caption"].Should().Be(article.Caption); + responseDocument.ManyData[0].Relationships.Should().BeNull(); var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); articleCaptured.Id.Should().Be(article.Id); @@ -565,7 +702,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_select_in_unknown_scope() + public async Task Cannot_select_on_unknown_resource_type() { // Arrange var route = "/api/v1/people?fields[doesNotExist]=id"; @@ -579,36 +716,17 @@ public async Task Cannot_select_in_unknown_scope() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); responseDocument.Errors[0].Title.Should().Be("The specified fieldset is invalid."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource 'people'."); + responseDocument.Errors[0].Detail.Should().Be("Resource type 'doesNotExist' does not exist."); responseDocument.Errors[0].Source.Parameter.Should().Be("fields[doesNotExist]"); } - [Fact] - public async Task Cannot_select_in_unknown_nested_scope() - { - // Arrange - var route = "/api/v1/people?fields[todoItems.doesNotExist]=id"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); - responseDocument.Errors[0].Title.Should().Be("The specified fieldset is invalid."); - responseDocument.Errors[0].Detail.Should().Be("Relationship 'doesNotExist' in 'todoItems.doesNotExist' does not exist on resource 'todoItems'."); - responseDocument.Errors[0].Source.Parameter.Should().Be("fields[todoItems.doesNotExist]"); - } - [Fact] public async Task Cannot_select_attribute_with_blocked_capability() { // Arrange var user = _userFaker.Generate(); - var route = $"/api/v1/users/{user.Id}?fields=password"; + var route = $"/api/v1/users/{user.Id}?fields[users]=password"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -620,7 +738,7 @@ public async Task Cannot_select_attribute_with_blocked_capability() responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadRequest); responseDocument.Errors[0].Title.Should().Be("Retrieving the requested attribute is not allowed."); responseDocument.Errors[0].Detail.Should().Be("Retrieving the attribute 'password' is not allowed."); - responseDocument.Errors[0].Source.Parameter.Should().Be("fields"); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields[users]"); } [Fact] @@ -642,7 +760,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/todoItems/{todoItem.StringId}?fields=calculatedValue"; + var route = $"/api/v1/todoItems/{todoItem.StringId}?fields[todoItems]=calculatedValue"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -654,10 +772,59 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Id.Should().Be(todoItem.StringId); responseDocument.SingleData.Attributes.Should().HaveCount(1); responseDocument.SingleData.Attributes["calculatedValue"].Should().Be(todoItem.CalculatedValue); + responseDocument.SingleData.Relationships.Should().BeNull(); var todoItemCaptured = (TodoItem) store.Resources.Should().ContainSingle(x => x is TodoItem).And.Subject.Single(); todoItemCaptured.CalculatedValue.Should().Be(todoItem.CalculatedValue); todoItemCaptured.Description.Should().Be(todoItem.Description); } + + [Fact] + public async Task Can_select_fields_on_resource_type_multiple_times() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var article = new Article + { + Caption = "One", + Url = "https://one.domain.com", + Author = new Author + { + LastName = "Smith" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Articles.Add(article); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/v1/articles/{article.StringId}?fields[articles]=url&fields[articles]=caption,url&fields[articles]=caption,author"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(article.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(2); + responseDocument.SingleData.Attributes["caption"].Should().Be(article.Caption); + responseDocument.SingleData.Attributes["url"].Should().Be(article.Url); + responseDocument.SingleData.Relationships.Should().HaveCount(1); + responseDocument.SingleData.Relationships["author"].Data.Should().BeNull(); + responseDocument.SingleData.Relationships["author"].Links.Self.Should().NotBeNull(); + responseDocument.SingleData.Relationships["author"].Links.Related.Should().NotBeNull(); + + var articleCaptured = (Article) store.Resources.Should().ContainSingle(x => x is Article).And.Subject.Single(); + articleCaptured.Id.Should().Be(article.Id); + articleCaptured.Caption.Should().Be(article.Caption); + articleCaptured.Url.Should().Be(articleCaptured.Url); + } } } diff --git a/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index c3f0f45abd..fac44d731d 100644 --- a/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -18,11 +18,11 @@ public SparseFieldSetParseTests() { _reader = new SparseFieldSetQueryStringParameterReader(Request, ResourceGraph); } - + [Theory] - [InlineData("fields", true)] + [InlineData("fields", false)] [InlineData("fields[articles]", true)] - [InlineData("fields[articles.revisions]", true)] + [InlineData("fields[]", true)] [InlineData("fieldset", false)] [InlineData("fields[", false)] [InlineData("fields]", false)] @@ -50,17 +50,20 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, } [Theory] - [InlineData("fields[", "id", "Field name expected.")] - [InlineData("fields[id]", "id", "Relationship 'id' does not exist on resource 'blogs'.")] - [InlineData("fields[articles.id]", "id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] - [InlineData("fields", "", "Attribute name expected.")] - [InlineData("fields", " ", "Unexpected whitespace.")] - [InlineData("fields", "id,articles", "Attribute 'articles' does not exist on resource 'blogs'.")] - [InlineData("fields", "id,articles.name", "Attribute 'articles.name' does not exist on resource 'blogs'.")] - [InlineData("fields[articles]", "id,tags", "Attribute 'tags' does not exist on resource 'articles'.")] - [InlineData("fields[articles.author.livingAddress]", "street,some", "Attribute 'some' does not exist on resource 'addresses'.")] - [InlineData("fields", "id(", ", expected.")] - [InlineData("fields", "id,", "Attribute name expected.")] + [InlineData("fields", "", "[ expected.")] + [InlineData("fields]", "", "[ expected.")] + [InlineData("fields[", "", "Resource type expected.")] + [InlineData("fields[]", "", "Resource type expected.")] + [InlineData("fields[ ]", "", "Unexpected whitespace.")] + [InlineData("fields[owner]", "", "Resource type 'owner' does not exist.")] + [InlineData("fields[owner.articles]", "id", "Resource type 'owner.articles' does not exist.")] + [InlineData("fields[articles]", "", "Field name expected.")] + [InlineData("fields[articles]", " ", "Unexpected whitespace.")] + [InlineData("fields[articles]", "some", "Field 'some' does not exist on resource 'articles'.")] + [InlineData("fields[articles]", "id,owner.name", "Field 'owner.name' does not exist on resource 'articles'.")] + [InlineData("fields[articles]", "id(", ", expected.")] + [InlineData("fields[articles]", "id,", "Field name expected.")] + [InlineData("fields[articles]", "author.id,", "Field 'author.id' does not exist on resource 'articles'.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { // Act @@ -78,13 +81,10 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin } [Theory] - [InlineData("fields", "id", null, "id")] - [InlineData("fields[articles]", "caption,url", "articles", "caption,url")] - [InlineData("fields[owner.articles]", "caption", "owner.articles", "caption")] - [InlineData("fields[articles.author]", "firstName,id", "articles.author", "firstName,id")] - [InlineData("fields[articles.author.livingAddress]", "street,zipCode", "articles.author.livingAddress", "street,zipCode")] - [InlineData("fields[articles.tags]", "name,id", "articles.tags", "name,id")] - public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected, string valueExpected) + [InlineData("fields[articles]", "caption,url,author", "articles(caption,url,author)")] + [InlineData("fields[articles]", "author,revisions,tags", "articles(author,revisions,tags)")] + [InlineData("fields[countries]", "id", "countries(id)")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected) { // Act _reader.Read(parameterName, parameterValue); @@ -93,7 +93,7 @@ public void Reader_Read_Succeeds(string parameterName, string parameterValue, st // Assert var scope = constraints.Select(x => x.Scope).Single(); - scope?.ToString().Should().Be(scopeExpected); + scope.Should().BeNull(); var value = constraints.Select(x => x.Expression).Single(); value.ToString().Should().Be(valueExpected); diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index 233e5568ec..7251e2fbe1 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -60,7 +60,7 @@ protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(List
  • (), GetResourceDefinitionAccessor(), GetSerializerSettingsProvider()); } protected IResourceObjectBuilderSettingsProvider GetSerializerSettingsProvider() @@ -95,7 +95,7 @@ protected ILinkBuilder GetLinkBuilder(TopLevelLinks top = null, ResourceLinks re protected IFieldsToSerialize GetSerializableFields() { var mock = new Mock(); - mock.Setup(m => m.GetAttributes(It.IsAny(), It.IsAny())).Returns((t, r) => _resourceGraph.GetResourceContext(t).Attributes); + mock.Setup(m => m.GetAttributes(It.IsAny())).Returns(t => _resourceGraph.GetResourceContext(t).Attributes); mock.Setup(m => m.GetRelationships(It.IsAny())).Returns(t => _resourceGraph.GetResourceContext(t).Relationships); return mock.Object; } diff --git a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs index 11100d3c01..00886e89cd 100644 --- a/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs +++ b/test/UnitTests/Serialization/Server/IncludedResourceObjectBuilderTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; @@ -171,7 +172,7 @@ private IncludedResourceObjectBuilder GetBuilder() var links = GetLinkBuilder(); var accessor = new Mock().Object; - return new IncludedResourceObjectBuilder(fields, links, _resourceGraph, accessor, GetSerializerSettingsProvider()); + return new IncludedResourceObjectBuilder(fields, links, _resourceGraph, Enumerable.Empty(), accessor, GetSerializerSettingsProvider()); } } } diff --git a/test/e2e/postman/JADNC_GettingStarted_PostmanCollection.json b/test/e2e/postman/JADNC_GettingStarted_PostmanCollection.json index 1772ca8103..bb00bae58d 100644 --- a/test/e2e/postman/JADNC_GettingStarted_PostmanCollection.json +++ b/test/e2e/postman/JADNC_GettingStarted_PostmanCollection.json @@ -142,7 +142,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{host}}/api/books?fields=title", + "raw": "{{host}}/api/books?fields[books]=title", "host": [ "{{host}}" ], @@ -152,7 +152,7 @@ ], "query": [ { - "key": "fields", + "key": "fields[books]", "value": "title" } ] From 1bc77a0e14cea3baa3f2357539c7905a0b559872 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 9 Dec 2020 16:33:55 +0100 Subject: [PATCH 2/2] Fixed: pass ID attribute in ResourceDefinition callback for relationships. --- .../Queries/Internal/QueryLayerComposer.cs | 14 +++++------- .../Queries/Internal/SparseFieldSetCache.cs | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index e7db3f8646..cca019af6e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -215,12 +215,9 @@ public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondary private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) { - var secondaryIdAttribute = GetIdAttribute(secondaryResourceContext); + var secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceContext); - var secondaryProjection = GetProjectionForSparseAttributeSet(secondaryResourceContext) ?? new Dictionary(); - secondaryProjection[secondaryIdAttribute] = null; - - return secondaryProjection; + return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, value => (QueryLayer)null); } /// @@ -233,13 +230,12 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, var innerInclude = secondaryLayer.Include; secondaryLayer.Include = null; - var primaryIdAttribute = GetIdAttribute(primaryResourceContext); - - var primaryProjection = GetProjectionForSparseAttributeSet(primaryResourceContext) ?? new Dictionary(); + var primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceContext); + var primaryProjection = primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, value => (QueryLayer)null); primaryProjection[secondaryRelationship] = secondaryLayer; - primaryProjection[primaryIdAttribute] = null; var primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); + var primaryIdAttribute = GetIdAttribute(primaryResourceContext); return new QueryLayer(primaryResourceContext) { diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs index 47bbf54b96..e3eb52a664 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs @@ -59,6 +59,8 @@ private static IDictionary> Bui public IReadOnlyCollection GetSparseFieldSetForQuery(ResourceContext resourceContext) { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + if (!_visitedTable.ContainsKey(resourceContext)) { var inputExpression = _lazySourceTable.Value.ContainsKey(resourceContext) @@ -77,6 +79,24 @@ public IReadOnlyCollection GetSparseFieldSetForQuery(Res return _visitedTable[resourceContext]; } + public IReadOnlyCollection GetIdAttributeSetForRelationshipQuery(ResourceContext resourceContext) + { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + + var idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); + var inputExpression = new SparseFieldSetExpression(new []{idAttribute}); + + // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). + var outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); + + var outputAttributes = outputExpression == null + ? new HashSet() + : outputExpression.Fields.OfType().ToHashSet(); + + outputAttributes.Add(idAttribute); + return outputAttributes; + } + public IReadOnlyCollection GetSparseFieldSetForSerializer(ResourceContext resourceContext) { if (!_visitedTable.ContainsKey(resourceContext)) @@ -107,6 +127,8 @@ public IReadOnlyCollection GetSparseFieldSetForSerialize private HashSet GetResourceFields(ResourceContext resourceContext) { + if (resourceContext == null) throw new ArgumentNullException(nameof(resourceContext)); + var fieldSet = new HashSet(); foreach (var attribute in resourceContext.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)))