Skip to content

Commit c7ca5b4

Browse files
committed
Allow derived types in the scope of sort/filter/page query string parameters
1 parent 5551bed commit c7ca5b4

22 files changed

+464
-126
lines changed

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

src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,26 +35,7 @@ public IncludeExpression Parse(string source, ResourceType resourceType)
3535

3636
protected virtual IncludeExpression ParseInclude(ResourceType resourceType)
3737
{
38-
ArgumentNullException.ThrowIfNull(resourceType);
39-
40-
var treeRoot = IncludeTreeNode.CreateRoot(resourceType);
41-
bool isAtStart = true;
42-
43-
while (TokenStack.Count > 0)
44-
{
45-
if (!isAtStart)
46-
{
47-
EatSingleCharacterToken(TokenKind.Comma);
48-
}
49-
else
50-
{
51-
isAtStart = false;
52-
}
53-
54-
ParseRelationshipChain(treeRoot);
55-
}
56-
57-
return treeRoot.ToExpression();
38+
return ParseCommaSeparatedSequenceOfRelationshipChains(resourceType);
5839
}
5940

6041
private void ValidateMaximumIncludeDepth(IncludeExpression include, int position)

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

src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,47 @@ protected void AssertTokenStackIsEmpty()
193193
}
194194
}
195195

196-
private protected void ParseRelationshipChain(IncludeTreeNode treeRoot)
196+
/// <summary>
197+
/// Parses a comma-separated sequence of relationship chains, taking relationships on derived types into account.
198+
/// </summary>
199+
protected IncludeExpression ParseCommaSeparatedSequenceOfRelationshipChains(ResourceType resourceType)
200+
{
201+
ArgumentNullException.ThrowIfNull(resourceType);
202+
203+
var treeRoot = IncludeTreeNode.CreateRoot(resourceType);
204+
bool isAtStart = true;
205+
206+
while (TokenStack.Count > 0)
207+
{
208+
if (!isAtStart)
209+
{
210+
EatSingleCharacterToken(TokenKind.Comma);
211+
}
212+
else
213+
{
214+
isAtStart = false;
215+
}
216+
217+
ParseRelationshipChain(treeRoot, false, null);
218+
}
219+
220+
return treeRoot.ToExpression();
221+
}
222+
223+
/// <summary>
224+
/// Parses a relationship chain that ends in a to-many relationship, taking relationships on derived types into account.
225+
/// </summary>
226+
protected IncludeExpression ParseRelationshipChainEndingInToMany(ResourceType resourceType, string? alternativeErrorMessage)
227+
{
228+
ArgumentNullException.ThrowIfNull(resourceType);
229+
230+
var treeRoot = IncludeTreeNode.CreateRoot(resourceType);
231+
ParseRelationshipChain(treeRoot, true, alternativeErrorMessage);
232+
233+
return treeRoot.ToExpression();
234+
}
235+
236+
private void ParseRelationshipChain(IncludeTreeNode treeRoot, bool requireChainEndsInToMany, string? alternativeErrorMessage)
197237
{
198238
// A relationship name usually matches a single relationship, even when overridden in derived types.
199239
// But in the following case, two relationships are matched on GET /shoppingBaskets?include=items:
@@ -221,29 +261,35 @@ private protected void ParseRelationshipChain(IncludeTreeNode treeRoot)
221261
// that there's currently no way to include Products without Articles. We could add such optional upcast syntax
222262
// in the future, if desired.
223263

224-
ReadOnlyCollection<IncludeTreeNode> children = ParseRelationshipName([treeRoot]);
264+
ReadOnlyCollection<IncludeTreeNode> children = ParseRelationshipName([treeRoot], requireChainEndsInToMany, alternativeErrorMessage);
225265

226266
while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period)
227267
{
228268
EatSingleCharacterToken(TokenKind.Period);
229269

230-
children = ParseRelationshipName(children);
270+
children = ParseRelationshipName(children, requireChainEndsInToMany, null);
231271
}
232272
}
233273

234-
private ReadOnlyCollection<IncludeTreeNode> ParseRelationshipName(IReadOnlyCollection<IncludeTreeNode> parents)
274+
private ReadOnlyCollection<IncludeTreeNode> ParseRelationshipName(IReadOnlyCollection<IncludeTreeNode> parents, bool requireChainEndsInToMany,
275+
string? alternativeErrorMessage)
235276
{
236277
int position = GetNextTokenPositionOrEnd();
237278

238279
if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text)
239280
{
240-
return LookupRelationshipName(token.Value!, parents, position);
281+
bool isAtEndOfChain = !TokenStack.TryPeek(out Token? nextToken) || nextToken.Kind != TokenKind.Period;
282+
bool requireToMany = requireChainEndsInToMany && isAtEndOfChain;
283+
284+
return LookupRelationshipName(token.Value!, parents, requireToMany, position);
241285
}
242286

243-
throw new QueryParseException("Relationship name expected.", position);
287+
string message = alternativeErrorMessage ?? (requireChainEndsInToMany ? "To-many relationship name expected." : "Relationship name expected.");
288+
throw new QueryParseException(message, position);
244289
}
245290

246-
private ReadOnlyCollection<IncludeTreeNode> LookupRelationshipName(string relationshipName, IReadOnlyCollection<IncludeTreeNode> parents, int position)
291+
private ReadOnlyCollection<IncludeTreeNode> LookupRelationshipName(string relationshipName, IReadOnlyCollection<IncludeTreeNode> parents,
292+
bool requireToMany, int position)
247293
{
248294
List<IncludeTreeNode> children = [];
249295
HashSet<RelationshipAttribute> relationshipsFound = [];
@@ -252,51 +298,58 @@ private ReadOnlyCollection<IncludeTreeNode> LookupRelationshipName(string relati
252298
{
253299
// Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy.
254300
// This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones.
255-
HashSet<RelationshipAttribute> relationships = GetRelationshipsInConcreteTypes(parent.Relationship.RightType, relationshipName);
301+
HashSet<RelationshipAttribute> relationships = GetRelationshipsInConcreteTypes(parent.Relationship.RightType, relationshipName, requireToMany);
256302

257303
if (relationships.Count > 0)
258304
{
259305
relationshipsFound.UnionWith(relationships);
260306

261307
RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray();
262-
IReadOnlyCollection<IncludeTreeNode> affectedChildren = parent.EnsureChildren(relationshipsToInclude);
308+
ReadOnlyCollection<IncludeTreeNode> affectedChildren = parent.EnsureChildren(relationshipsToInclude);
263309
children.AddRange(affectedChildren);
264310
}
265311
}
266312

267-
AssertRelationshipsFound(relationshipsFound, relationshipName, parents, position);
313+
AssertRelationshipsFound(relationshipsFound, relationshipName, requireToMany, parents, position);
268314
AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, position);
269315

270316
return children.AsReadOnly();
271317
}
272318

273-
private static HashSet<RelationshipAttribute> GetRelationshipsInConcreteTypes(ResourceType resourceType, string relationshipName)
319+
private static HashSet<RelationshipAttribute> GetRelationshipsInConcreteTypes(ResourceType resourceType, string relationshipName, bool requireToMany)
274320
{
275321
HashSet<RelationshipAttribute> relationshipsToInclude = [];
276322

277323
foreach (RelationshipAttribute relationship in resourceType.GetRelationshipsInTypeOrDerived(relationshipName))
278324
{
279-
if (!relationship.LeftType.ClrType.IsAbstract)
325+
if (!requireToMany || relationship is HasManyAttribute)
280326
{
281-
relationshipsToInclude.Add(relationship);
327+
if (!relationship.LeftType.ClrType.IsAbstract)
328+
{
329+
relationshipsToInclude.Add(relationship);
330+
}
282331
}
283332

284-
IncludeRelationshipsFromConcreteDerivedTypes(relationship, relationshipsToInclude);
333+
IncludeRelationshipsFromConcreteDerivedTypes(relationship, requireToMany, relationshipsToInclude);
285334
}
286335

287336
return relationshipsToInclude;
288337
}
289338

290-
private static void IncludeRelationshipsFromConcreteDerivedTypes(RelationshipAttribute relationship, HashSet<RelationshipAttribute> relationshipsToInclude)
339+
private static void IncludeRelationshipsFromConcreteDerivedTypes(RelationshipAttribute relationship, bool requireToMany,
340+
HashSet<RelationshipAttribute> relationshipsToInclude)
291341
{
292342
foreach (ResourceType derivedType in relationship.LeftType.GetAllConcreteDerivedTypes())
293343
{
294-
RelationshipAttribute relationshipInDerived = derivedType.GetRelationshipByPublicName(relationship.PublicName);
295-
relationshipsToInclude.Add(relationshipInDerived);
344+
if (!requireToMany || relationship is HasManyAttribute)
345+
{
346+
RelationshipAttribute relationshipInDerived = derivedType.GetRelationshipByPublicName(relationship.PublicName);
347+
relationshipsToInclude.Add(relationshipInDerived);
348+
}
296349
}
297350
}
298351

299-
private static void AssertRelationshipsFound(HashSet<RelationshipAttribute> relationshipsFound, string relationshipName,
352+
private static void AssertRelationshipsFound(HashSet<RelationshipAttribute> relationshipsFound, string relationshipName, bool requireToMany,
300353
IReadOnlyCollection<IncludeTreeNode> parents, int position)
301354
{
302355
if (relationshipsFound.Count > 0)
@@ -308,13 +361,13 @@ private static void AssertRelationshipsFound(HashSet<RelationshipAttribute> rela
308361

309362
bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0);
310363

311-
string message = GetErrorMessageForNoneFound(relationshipName, parentResourceTypes, hasDerivedTypes);
364+
string message = GetErrorMessageForNoneFound(relationshipName, requireToMany, parentResourceTypes, hasDerivedTypes);
312365
throw new QueryParseException(message, position);
313366
}
314367

315-
private static string GetErrorMessageForNoneFound(string relationshipName, ResourceType[] parentResourceTypes, bool hasDerivedTypes)
368+
private static string GetErrorMessageForNoneFound(string relationshipName, bool requireToMany, ResourceType[] parentResourceTypes, bool hasDerivedTypes)
316369
{
317-
var builder = new StringBuilder($"Relationship '{relationshipName}'");
370+
var builder = new StringBuilder($"{(requireToMany ? "To-many relationship" : "Relationship")} '{relationshipName}'");
318371

319372
if (parentResourceTypes.Length == 1)
320373
{
@@ -345,7 +398,7 @@ private void AssertAtLeastOneCanBeIncluded(HashSet<RelationshipAttribute> relati
345398
}
346399
}
347400

348-
internal sealed class IncludeTreeNode
401+
private sealed class IncludeTreeNode
349402
{
350403
private readonly Dictionary<RelationshipAttribute, IncludeTreeNode> _children = [];
351404

@@ -362,7 +415,7 @@ public static IncludeTreeNode CreateRoot(ResourceType resourceType)
362415
return new IncludeTreeNode(relationship);
363416
}
364417

365-
public IReadOnlyCollection<IncludeTreeNode> EnsureChildren(RelationshipAttribute[] relationships)
418+
public ReadOnlyCollection<IncludeTreeNode> EnsureChildren(RelationshipAttribute[] relationships)
366419
{
367420
foreach (RelationshipAttribute relationship in relationships)
368421
{

src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs

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

65
namespace JsonApiDotNetCore.Queries.Parsing;
76

@@ -36,13 +35,13 @@ protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterS
3635

3736
var name = new LiteralConstantExpression(token.Value!);
3837

39-
ResourceFieldChainExpression? scope = null;
38+
IncludeExpression? scope = null;
4039

4140
if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket)
4241
{
4342
TokenStack.Pop();
4443

45-
scope = ParseFieldChain(BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None, resourceType, null);
44+
scope = ParseRelationshipChainEndingInToMany(resourceType, null);
4645

4746
EatSingleCharacterToken(TokenKind.CloseBracket);
4847
}

src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ public static class BuiltInPatterns
1212
public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A");
1313
public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]");
1414
public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M");
15+
16+
[Obsolete("""This is no longer used and will be removed in a future version. Instead, use: FieldChainPattern.Parse("R*M")""")]
1517
public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M");
1618
}

src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,16 @@ private void ReadSingleValue(string parameterName, string parameterValue)
9696
(name, value) = LegacyConverter.Convert(name, value);
9797
}
9898

99-
ResourceFieldChainExpression? scope = GetScope(name);
99+
IncludeExpression? scopeInclude = GetScope(name);
100100
parameterNameIsValid = true;
101101

102-
FilterExpression filter = GetFilter(value, scope);
103-
StoreFilterInScope(filter, scope);
102+
foreach (ResourceFieldChainExpression? scopeChain in scopeInclude == null
103+
? FieldChainInGlobalScope
104+
: IncludeChainConverter.Instance.GetRelationshipChains(scopeInclude))
105+
{
106+
FilterExpression filter = GetFilter(value, scopeChain);
107+
StoreFilterInScope(filter, scopeChain);
108+
}
104109
}
105110
catch (QueryParseException exception)
106111
{
@@ -112,7 +117,7 @@ private void ReadSingleValue(string parameterName, string parameterValue)
112117
}
113118
}
114119

115-
private ResourceFieldChainExpression? GetScope(string parameterName)
120+
private IncludeExpression? GetScope(string parameterName)
116121
{
117122
QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType);
118123

0 commit comments

Comments
 (0)