Skip to content

Commit 23be010

Browse files
authored
Allow derived inclusion chains in filter/sort/page query strings (#1756)
* Simplify IQueryStringParameterScopeParser * Expose source text on parser base class * Move parsing of include chains into base class * Allow derived types in the scope of sort/filter/page query string parameters
1 parent f20d94d commit 23be010

26 files changed

+691
-367
lines changed

src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ namespace JsonApiDotNetCore.Queries.Expressions;
88
/// </summary>
99
internal sealed class IncludeChainConverter
1010
{
11+
public static IncludeChainConverter Instance { get; } = new();
12+
13+
private IncludeChainConverter()
14+
{
15+
}
16+
1117
/// <summary>
1218
/// Converts a tree of inclusions into a set of relationship chains.
1319
/// </summary>

src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ namespace JsonApiDotNetCore.Queries.Expressions;
1313
[PublicAPI]
1414
public class IncludeExpression : QueryExpression
1515
{
16-
private static readonly IncludeChainConverter IncludeChainConverter = new();
17-
1816
public static readonly IncludeExpression Empty = new();
1917

2018
/// <summary>
@@ -51,7 +49,7 @@ public override string ToFullString()
5149

5250
private string InnerToString(bool toFullString)
5351
{
54-
IReadOnlyCollection<ResourceFieldChainExpression> chains = IncludeChainConverter.GetRelationshipChains(this);
52+
IReadOnlyCollection<ResourceFieldChainExpression> chains = IncludeChainConverter.Instance.GetRelationshipChains(this);
5553
return string.Join(",", chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).Distinct().OrderBy(name => name));
5654
}
5755

src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ namespace JsonApiDotNetCore.Queries.Expressions;
1010
/// .
1111
/// </summary>
1212
[PublicAPI]
13-
public class PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value, int position) : QueryExpression
13+
public class PaginationElementQueryStringValueExpression(IncludeExpression? scope, int value, int position) : QueryExpression
1414
{
1515
/// <summary>
16-
/// The relationship this pagination applies to. Chain format: zero or more relationships, followed by a to-many relationship.
16+
/// The relationship this pagination applies to. Format: zero or more relationships, followed by a to-many relationship.
1717
/// </summary>
18-
public ResourceFieldChainExpression? Scope { get; } = scope;
18+
public IncludeExpression? Scope { get; } = scope;
1919

2020
/// <summary>
2121
/// The numeric pagination value.

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression exp
205205
public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument)
206206
{
207207
var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression;
208-
ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null;
208+
IncludeExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as IncludeExpression : null;
209209

210210
if (newParameterName != null)
211211
{
@@ -226,7 +226,7 @@ public override QueryExpression VisitPaginationQueryStringValue(PaginationQueryS
226226

227227
public override QueryExpression VisitPaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument)
228228
{
229-
ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null;
229+
IncludeExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as IncludeExpression : null;
230230

231231
var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value, expression.Position);
232232
return newExpression.Equals(expression) ? expression : newExpression;

src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ public class QueryStringParameterScopeExpression : QueryExpression
2525
/// The scope this parameter value applies to, or <c>null</c> for the URL endpoint scope. Chain format for the filter/sort parameters: an optional list
2626
/// of relationships, followed by a to-many relationship.
2727
/// </summary>
28-
public ResourceFieldChainExpression? Scope { get; }
28+
public IncludeExpression? Scope { get; }
2929

30-
public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope)
30+
public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, IncludeExpression? scope)
3131
{
3232
ArgumentNullException.ThrowIfNull(parameterName);
3333

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
using JsonApiDotNetCore.Configuration;
22
using JsonApiDotNetCore.Queries.Expressions;
3-
using JsonApiDotNetCore.QueryStrings.FieldChains;
43

54
namespace JsonApiDotNetCore.Queries.Parsing;
65

76
/// <summary>
8-
/// Parses the JSON:API 'sort' and 'filter' query string parameter names, which contain a resource field chain that indicates the scope its query string
9-
/// parameter value applies to.
7+
/// Parser for the JSON:API 'sort' and 'filter' query string parameter names, which indicate the scope their query string parameter value applies to. The
8+
/// value consists of an optional relationship chain ending in a to-many relationship, surrounded by brackets.
109
/// </summary>
1110
public interface IQueryStringParameterScopeParser
1211
{
@@ -20,11 +19,5 @@ public interface IQueryStringParameterScopeParser
2019
/// <param name="resourceType">
2120
/// The resource type used to lookup JSON:API fields that are referenced in <paramref name="source" />.
2221
/// </param>
23-
/// <param name="pattern">
24-
/// The pattern that the field chain in <paramref name="source" /> must match.
25-
/// </param>
26-
/// <param name="options">
27-
/// The match options for <paramref name="pattern" />.
28-
/// </param>
29-
QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options);
22+
QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType);
3023
}
Lines changed: 3 additions & 247 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
using System.Collections.Immutable;
2-
using System.Collections.ObjectModel;
3-
using System.Text;
41
using JetBrains.Annotations;
52
using JsonApiDotNetCore.Configuration;
6-
using JsonApiDotNetCore.Errors;
73
using JsonApiDotNetCore.Queries.Expressions;
84
using JsonApiDotNetCore.Resources.Annotations;
95

@@ -29,190 +25,17 @@ public IncludeExpression Parse(string source, ResourceType resourceType)
2925

3026
Tokenize(source);
3127

32-
IncludeExpression expression = ParseInclude(source, resourceType);
28+
IncludeExpression expression = ParseInclude(resourceType);
3329

3430
AssertTokenStackIsEmpty();
3531
ValidateMaximumIncludeDepth(expression, 0);
3632

3733
return expression;
3834
}
3935

40-
protected virtual IncludeExpression ParseInclude(string source, ResourceType resourceType)
36+
protected virtual IncludeExpression ParseInclude(ResourceType resourceType)
4137
{
42-
ArgumentNullException.ThrowIfNull(source);
43-
ArgumentNullException.ThrowIfNull(resourceType);
44-
45-
var treeRoot = IncludeTreeNode.CreateRoot(resourceType);
46-
bool isAtStart = true;
47-
48-
while (TokenStack.Count > 0)
49-
{
50-
if (!isAtStart)
51-
{
52-
EatSingleCharacterToken(TokenKind.Comma);
53-
}
54-
else
55-
{
56-
isAtStart = false;
57-
}
58-
59-
ParseRelationshipChain(source, treeRoot);
60-
}
61-
62-
return treeRoot.ToExpression();
63-
}
64-
65-
private void ParseRelationshipChain(string source, IncludeTreeNode treeRoot)
66-
{
67-
// A relationship name usually matches a single relationship, even when overridden in derived types.
68-
// But in the following case, two relationships are matched on GET /shoppingBaskets?include=items:
69-
//
70-
// public abstract class ShoppingBasket : Identifiable<long>
71-
// {
72-
// }
73-
//
74-
// public sealed class SilverShoppingBasket : ShoppingBasket
75-
// {
76-
// [HasMany]
77-
// public ISet<Article> Items { get; get; }
78-
// }
79-
//
80-
// public sealed class PlatinumShoppingBasket : ShoppingBasket
81-
// {
82-
// [HasMany]
83-
// public ISet<Product> Items { get; get; }
84-
// }
85-
//
86-
// Now if the include chain has subsequent relationships, we need to scan both Items relationships for matches,
87-
// which is why ParseRelationshipName returns a collection.
88-
//
89-
// The advantage of this unfolding is we don't require callers to upcast in relationship chains. The downside is
90-
// that there's currently no way to include Products without Articles. We could add such optional upcast syntax
91-
// in the future, if desired.
92-
93-
ReadOnlyCollection<IncludeTreeNode> children = ParseRelationshipName(source, [treeRoot]);
94-
95-
while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period)
96-
{
97-
EatSingleCharacterToken(TokenKind.Period);
98-
99-
children = ParseRelationshipName(source, children);
100-
}
101-
}
102-
103-
private ReadOnlyCollection<IncludeTreeNode> ParseRelationshipName(string source, IReadOnlyCollection<IncludeTreeNode> parents)
104-
{
105-
int position = GetNextTokenPositionOrEnd();
106-
107-
if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text)
108-
{
109-
return LookupRelationshipName(token.Value!, parents, source, position);
110-
}
111-
112-
throw new QueryParseException("Relationship name expected.", position);
113-
}
114-
115-
private static ReadOnlyCollection<IncludeTreeNode> LookupRelationshipName(string relationshipName, IReadOnlyCollection<IncludeTreeNode> parents,
116-
string source, int position)
117-
{
118-
List<IncludeTreeNode> children = [];
119-
HashSet<RelationshipAttribute> relationshipsFound = [];
120-
121-
foreach (IncludeTreeNode parent in parents)
122-
{
123-
// Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy.
124-
// This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones.
125-
HashSet<RelationshipAttribute> relationships = GetRelationshipsInConcreteTypes(parent.Relationship.RightType, relationshipName);
126-
127-
if (relationships.Count > 0)
128-
{
129-
relationshipsFound.UnionWith(relationships);
130-
131-
RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray();
132-
ReadOnlyCollection<IncludeTreeNode> affectedChildren = parent.EnsureChildren(relationshipsToInclude);
133-
children.AddRange(affectedChildren);
134-
}
135-
}
136-
137-
AssertRelationshipsFound(relationshipsFound, relationshipName, parents, position);
138-
AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, source, position);
139-
140-
return children.AsReadOnly();
141-
}
142-
143-
private static HashSet<RelationshipAttribute> GetRelationshipsInConcreteTypes(ResourceType resourceType, string relationshipName)
144-
{
145-
HashSet<RelationshipAttribute> relationshipsToInclude = [];
146-
147-
foreach (RelationshipAttribute relationship in resourceType.GetRelationshipsInTypeOrDerived(relationshipName))
148-
{
149-
if (!relationship.LeftType.ClrType.IsAbstract)
150-
{
151-
relationshipsToInclude.Add(relationship);
152-
}
153-
154-
IncludeRelationshipsFromConcreteDerivedTypes(relationship, relationshipsToInclude);
155-
}
156-
157-
return relationshipsToInclude;
158-
}
159-
160-
private static void IncludeRelationshipsFromConcreteDerivedTypes(RelationshipAttribute relationship, HashSet<RelationshipAttribute> relationshipsToInclude)
161-
{
162-
foreach (ResourceType derivedType in relationship.LeftType.GetAllConcreteDerivedTypes())
163-
{
164-
RelationshipAttribute relationshipInDerived = derivedType.GetRelationshipByPublicName(relationship.PublicName);
165-
relationshipsToInclude.Add(relationshipInDerived);
166-
}
167-
}
168-
169-
private static void AssertRelationshipsFound(HashSet<RelationshipAttribute> relationshipsFound, string relationshipName,
170-
IReadOnlyCollection<IncludeTreeNode> parents, int position)
171-
{
172-
if (relationshipsFound.Count > 0)
173-
{
174-
return;
175-
}
176-
177-
ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray();
178-
179-
bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0);
180-
181-
string message = GetErrorMessageForNoneFound(relationshipName, parentResourceTypes, hasDerivedTypes);
182-
throw new QueryParseException(message, position);
183-
}
184-
185-
private static string GetErrorMessageForNoneFound(string relationshipName, ResourceType[] parentResourceTypes, bool hasDerivedTypes)
186-
{
187-
var builder = new StringBuilder($"Relationship '{relationshipName}'");
188-
189-
if (parentResourceTypes.Length == 1)
190-
{
191-
builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'");
192-
}
193-
else
194-
{
195-
string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'"));
196-
builder.Append($" does not exist on any of the resource types {typeNames}");
197-
}
198-
199-
builder.Append(hasDerivedTypes ? " or any of its derived types." : ".");
200-
201-
return builder.ToString();
202-
}
203-
204-
private static void AssertAtLeastOneCanBeIncluded(HashSet<RelationshipAttribute> relationshipsFound, string relationshipName, string source, int position)
205-
{
206-
if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked()))
207-
{
208-
ResourceType resourceType = relationshipsFound.First().LeftType;
209-
string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed.";
210-
211-
var exception = new QueryParseException(message, position);
212-
string specificMessage = exception.GetMessageWithPosition(source);
213-
214-
throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage);
215-
}
38+
return ParseCommaSeparatedSequenceOfRelationshipChains(resourceType);
21639
}
21740

21841
private void ValidateMaximumIncludeDepth(IncludeExpression include, int position)
@@ -247,71 +70,4 @@ private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression include
24770

24871
parentChain.Pop();
24972
}
250-
251-
private sealed class IncludeTreeNode
252-
{
253-
private readonly Dictionary<RelationshipAttribute, IncludeTreeNode> _children = [];
254-
255-
public RelationshipAttribute Relationship { get; }
256-
257-
private IncludeTreeNode(RelationshipAttribute relationship)
258-
{
259-
Relationship = relationship;
260-
}
261-
262-
public static IncludeTreeNode CreateRoot(ResourceType resourceType)
263-
{
264-
var relationship = new HiddenRootRelationshipAttribute(resourceType);
265-
return new IncludeTreeNode(relationship);
266-
}
267-
268-
public ReadOnlyCollection<IncludeTreeNode> EnsureChildren(RelationshipAttribute[] relationships)
269-
{
270-
foreach (RelationshipAttribute relationship in relationships)
271-
{
272-
if (!_children.ContainsKey(relationship))
273-
{
274-
var newChild = new IncludeTreeNode(relationship);
275-
_children.Add(relationship, newChild);
276-
}
277-
}
278-
279-
return _children.Where(pair => relationships.Contains(pair.Key)).Select(pair => pair.Value).ToArray().AsReadOnly();
280-
}
281-
282-
public IncludeExpression ToExpression()
283-
{
284-
IncludeElementExpression element = ToElementExpression();
285-
286-
if (element.Relationship is HiddenRootRelationshipAttribute)
287-
{
288-
return element.Children.Count > 0 ? new IncludeExpression(element.Children) : IncludeExpression.Empty;
289-
}
290-
291-
return new IncludeExpression(ImmutableHashSet.Create(element));
292-
}
293-
294-
private IncludeElementExpression ToElementExpression()
295-
{
296-
IImmutableSet<IncludeElementExpression> elementChildren = _children.Values.Select(child => child.ToElementExpression()).ToImmutableHashSet();
297-
return new IncludeElementExpression(Relationship, elementChildren);
298-
}
299-
300-
public override string ToString()
301-
{
302-
IncludeExpression include = ToExpression();
303-
return include.ToFullString();
304-
}
305-
306-
private sealed class HiddenRootRelationshipAttribute : RelationshipAttribute
307-
{
308-
public HiddenRootRelationshipAttribute(ResourceType rightType)
309-
{
310-
ArgumentNullException.ThrowIfNull(rightType);
311-
312-
RightType = rightType;
313-
PublicName = "<<root>>";
314-
}
315-
}
316-
}
31773
}

src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using JetBrains.Annotations;
33
using JsonApiDotNetCore.Configuration;
44
using JsonApiDotNetCore.Queries.Expressions;
5-
using JsonApiDotNetCore.QueryStrings.FieldChains;
65

76
namespace JsonApiDotNetCore.Queries.Parsing;
87

@@ -57,8 +56,7 @@ protected virtual PaginationElementQueryStringValueExpression ParsePaginationEle
5756
return new PaginationElementQueryStringValueExpression(null, number.Value, position);
5857
}
5958

60-
ResourceFieldChainExpression scope = ParseFieldChain(BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None, resourceType,
61-
"Number or relationship name expected.");
59+
IncludeExpression scope = ParseRelationshipChainEndingInToMany(resourceType, "Number or relationship name expected.");
6260

6361
EatSingleCharacterToken(TokenKind.Colon);
6462

0 commit comments

Comments
 (0)