Skip to content

Changes usage of fields parameter to be json:api spec compliant #904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions benchmarks/Query/QueryParserBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -65,7 +67,7 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr

var readers = new List<IQueryStringParameterReader>
{
filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader
includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader
};

return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance);
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions docs/internals/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion docs/request-examples/004_GET_Books-PublishYear.ps1
Original file line number Diff line number Diff line change
@@ -1 +1 @@
curl -s -f http://localhost:14141/api/books?fields=publishYear
curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear
18 changes: 12 additions & 6 deletions docs/usage/reading/sparse-fieldset-selection.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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).
2 changes: 1 addition & 1 deletion docs/usage/resources/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/usage/resources/resource-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]`.
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/writing/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

{
...
Expand Down
4 changes: 2 additions & 2 deletions docs/usage/writing/updating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

{
...
Expand Down
2 changes: 0 additions & 2 deletions src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using JsonApiDotNetCore.Configuration;

namespace JsonApiDotNetCore.Queries.Expressions
{
Expand Down Expand Up @@ -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<ResourceContext, SparseFieldSetExpression>();

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@
namespace JsonApiDotNetCore.Queries.Expressions
{
/// <summary>
/// Represents a sparse fieldset, resulting from text such as: firstName,lastName
/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles
/// </summary>
public class SparseFieldSetExpression : QueryExpression
{
public IReadOnlyCollection<AttrAttribute> Attributes { get; }
public IReadOnlyCollection<ResourceFieldAttribute> Fields { get; }

public SparseFieldSetExpression(IReadOnlyCollection<AttrAttribute> attributes)
public SparseFieldSetExpression(IReadOnlyCollection<ResourceFieldAttribute> 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));
}
}

Expand All @@ -29,7 +29,7 @@ public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgum

public override string ToString()
{
return string.Join(",", Attributes.Select(child => child.PublicName));
return string.Join(",", Fields.Select(child => child.PublicName));
}

public override bool Equals(object obj)
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,76 +10,76 @@ namespace JsonApiDotNetCore.Queries.Expressions
public static class SparseFieldSetExpressionExtensions
{
public static SparseFieldSetExpression Including<TResource>(this SparseFieldSetExpression sparseFieldSet,
Expression<Func<TResource, dynamic>> attributeSelector, IResourceGraph resourceGraph)
Expression<Func<TResource, dynamic>> 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)
{
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<TResource>(this SparseFieldSetExpression sparseFieldSet,
Expression<Func<TResource, dynamic>> attributeSelector, IResourceGraph resourceGraph)
Expression<Func<TResource, dynamic>> 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)
{
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);
}
}
}
Loading