Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 23be010

Browse files
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(stringsource,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)

AltStyle によって変換されたページ (->オリジナル) /