Skip to content

Commit 58564ec

Browse files
committed
Add example for 'length' custom filter/sort function
1 parent 10b617d commit 58564ec

17 files changed

+743
-75
lines changed

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -105,25 +105,11 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume
105105

106106
public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument)
107107
{
108-
SortElementExpression? newExpression = null;
108+
QueryExpression? newTarget = Visit(expression.Target, argument);
109109

110-
if (expression.Count != null)
111-
{
112-
if (Visit(expression.Count, argument) is CountExpression newCount)
113-
{
114-
newExpression = new SortElementExpression(newCount, expression.IsAscending);
115-
}
116-
}
117-
else if (expression.TargetAttribute != null)
118-
{
119-
if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute)
120-
{
121-
newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending);
122-
}
123-
}
124-
125-
if (newExpression != null)
110+
if (newTarget != null)
126111
{
112+
var newExpression = new SortElementExpression(newTarget, expression.IsAscending);
127113
return newExpression.Equals(expression) ? expression : newExpression;
128114
}
129115

src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,14 @@ namespace JsonApiDotNetCore.Queries.Expressions;
99
[PublicAPI]
1010
public class SortElementExpression : QueryExpression
1111
{
12-
public ResourceFieldChainExpression? TargetAttribute { get; }
13-
public CountExpression? Count { get; }
12+
public QueryExpression Target { get; }
1413
public bool IsAscending { get; }
1514

16-
public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending)
15+
public SortElementExpression(QueryExpression target, bool isAscending)
1716
{
18-
ArgumentGuard.NotNull(targetAttribute);
17+
ArgumentGuard.NotNull(target);
1918

20-
TargetAttribute = targetAttribute;
21-
IsAscending = isAscending;
22-
}
23-
24-
public SortElementExpression(CountExpression count, bool isAscending)
25-
{
26-
ArgumentGuard.NotNull(count);
27-
28-
Count = count;
19+
Target = target;
2920
IsAscending = isAscending;
3021
}
3122

@@ -53,14 +44,7 @@ private string InnerToString(bool toFullString)
5344
builder.Append('-');
5445
}
5546

56-
if (TargetAttribute != null)
57-
{
58-
builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute);
59-
}
60-
else if (Count != null)
61-
{
62-
builder.Append(toFullString ? Count.ToFullString() : Count);
63-
}
47+
builder.Append(toFullString ? Target.ToFullString() : Target);
6448

6549
return builder.ToString();
6650
}
@@ -79,11 +63,11 @@ public override bool Equals(object? obj)
7963

8064
var other = (SortElementExpression)obj;
8165

82-
return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending;
66+
return Equals(Target, other.Target) && IsAscending == other.IsAscending;
8367
}
8468

8569
public override int GetHashCode()
8670
{
87-
return HashCode.Combine(TargetAttribute, Count, IsAscending);
71+
return HashCode.Combine(Target, IsAscending);
8872
}
8973
}

src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
namespace JsonApiDotNetCore.Queries.Expressions;
55

66
/// <summary>
7-
/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt
7+
/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt,count(children)
88
/// </summary>
99
[PublicAPI]
1010
public class SortExpression : QueryExpression

src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,6 @@ private string EatFieldChain(string? alternativeErrorMessage)
112112
}
113113
}
114114

115-
private protected CountExpression? TryParseCount(FieldChainPatternMatchOptions options, ResourceType resourceType)
116-
{
117-
if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: Keywords.Count })
118-
{
119-
TokenStack.Pop();
120-
121-
EatSingleCharacterToken(TokenKind.OpenParen);
122-
123-
ResourceFieldChainExpression targetCollection = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, options, resourceType, null);
124-
125-
EatSingleCharacterToken(TokenKind.CloseParen);
126-
127-
return new CountExpression(targetCollection);
128-
}
129-
130-
return null;
131-
}
132-
133115
/// <summary>
134116
/// Consumes a token containing the expected text from the top of <see cref="TokenStack" />. Throws a <see cref="QueryParseException" /> if a different
135117
/// token kind is at the top, it contains a different text, or if there are no more tokens available.

src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,22 +75,61 @@ protected virtual SortElementExpression ParseSortElement(ResourceType resourceTy
7575
// In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends
7676
// on which attribute is used.
7777
//
78-
// Because there is no syntax to pick one, we fail with an error. We could add optional upcast syntax
78+
// Because there is no syntax to pick one, ParseFieldChain() fails with an error. We could add optional upcast syntax
7979
// (which would be required in this case) in the future to make it work, if desired.
8080

81-
CountExpression? count = TryParseCount(FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType);
81+
QueryExpression target;
8282

83-
if (count != null)
83+
if (TokenStack.TryPeek(out nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!))
8484
{
85-
return new SortElementExpression(count, isAscending);
85+
target = ParseFunction(resourceType);
8686
}
87+
else
88+
{
89+
string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected.";
90+
target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage);
91+
}
92+
93+
return new SortElementExpression(target, isAscending);
94+
}
95+
96+
protected virtual bool IsFunction(string name)
97+
{
98+
ArgumentGuard.NotNullNorEmpty(name);
99+
100+
return name == Keywords.Count;
101+
}
102+
103+
protected virtual FunctionExpression ParseFunction(ResourceType resourceType)
104+
{
105+
ArgumentGuard.NotNull(resourceType);
106+
107+
if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text)
108+
{
109+
switch (nextToken.Value)
110+
{
111+
case Keywords.Count:
112+
{
113+
return ParseCount(resourceType);
114+
}
115+
}
116+
}
117+
118+
int position = GetNextTokenPositionOrEnd();
119+
throw new QueryParseException("Count function expected.", position);
120+
}
121+
122+
private CountExpression ParseCount(ResourceType resourceType)
123+
{
124+
EatText(Keywords.Count);
125+
EatSingleCharacterToken(TokenKind.OpenParen);
87126

88-
string errorMessage = isAscending ? "-, count function or field name expected." : "Count function or field name expected.";
127+
ResourceFieldChainExpression targetCollection =
128+
ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null);
89129

90-
ResourceFieldChainExpression targetAttribute = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute,
91-
FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage);
130+
EatSingleCharacterToken(TokenKind.CloseParen);
92131

93-
return new SortElementExpression(targetAttribute, isAscending);
132+
return new CountExpression(targetCollection);
94133
}
95134

96135
protected override void ValidateField(ResourceFieldAttribute field, int position)

src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@ public override Expression VisitSort(SortExpression expression, QueryClauseBuild
3030

3131
public override Expression VisitSortElement(SortElementExpression expression, QueryClauseBuilderContext context)
3232
{
33-
Expression body = expression.Count != null ? Visit(expression.Count, context) : Visit(expression.TargetAttribute!, context);
34-
33+
Expression body = Visit(expression.Target, context);
3534
LambdaExpression lambda = Expression.Lambda(body, context.LambdaScope.Parameter);
36-
3735
string operationName = GetOperationName(expression.IsAscending, context);
3836

3937
return ExtensionMethodCall(context.Source, operationName, body.Type, lambda, context);

src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public virtual IImmutableSet<IncludeElementExpression> OnApplyIncludes(IImmutabl
6363
/// });
6464
/// ]]></code>
6565
/// </example>
66-
protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors)
66+
protected virtual SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors)
6767
{
6868
ArgumentGuard.NotNullNorEmpty(keySelectors);
6969

test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,12 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg
117117
{
118118
if (IsSortOnCarId(sortElement))
119119
{
120-
ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute);
120+
var fieldChain = (ResourceFieldChainExpression)sortElement.Target;
121+
122+
ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(fieldChain, _regionIdAttribute);
121123
elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending));
122124

123-
ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute);
125+
ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(fieldChain, _licensePlateAttribute);
124126
elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending));
125127
}
126128
else
@@ -134,9 +136,9 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg
134136

135137
private static bool IsSortOnCarId(SortElementExpression sortElement)
136138
{
137-
if (sortElement.TargetAttribute != null)
139+
if (sortElement.Target is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute)
138140
{
139-
PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property;
141+
PropertyInfo property = attribute.Property;
140142

141143
if (IsCarId(property))
142144
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Text;
2+
using JsonApiDotNetCore;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength;
6+
7+
/// <summary>
8+
/// Represents the "length" function, resulting from text such as: length(title)
9+
/// </summary>
10+
internal sealed class LengthExpression : FunctionExpression
11+
{
12+
public const string Keyword = "length";
13+
14+
public ResourceFieldChainExpression TargetAttribute { get; }
15+
16+
public override Type ReturnType { get; } = typeof(int);
17+
18+
public LengthExpression(ResourceFieldChainExpression targetAttribute)
19+
{
20+
ArgumentGuard.NotNull(targetAttribute);
21+
22+
TargetAttribute = targetAttribute;
23+
}
24+
25+
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
26+
{
27+
return visitor.DefaultVisit(this, argument);
28+
}
29+
30+
public override string ToString()
31+
{
32+
return InnerToString(false);
33+
}
34+
35+
public override string ToFullString()
36+
{
37+
return InnerToString(true);
38+
}
39+
40+
private string InnerToString(bool toFullString)
41+
{
42+
var builder = new StringBuilder();
43+
44+
builder.Append(Keyword);
45+
builder.Append('(');
46+
builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute);
47+
builder.Append(')');
48+
49+
return builder.ToString();
50+
}
51+
52+
public override bool Equals(object? obj)
53+
{
54+
if (ReferenceEquals(this, obj))
55+
{
56+
return true;
57+
}
58+
59+
if (obj is null || GetType() != obj.GetType())
60+
{
61+
return false;
62+
}
63+
64+
var other = (LengthExpression)obj;
65+
66+
return TargetAttribute.Equals(other.TargetAttribute);
67+
}
68+
69+
public override int GetHashCode()
70+
{
71+
return TargetAttribute.GetHashCode();
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System.ComponentModel.Design;
2+
using System.Net;
3+
using FluentAssertions;
4+
using JsonApiDotNetCore.Errors;
5+
using JsonApiDotNetCore.Queries;
6+
using JsonApiDotNetCore.Queries.Expressions;
7+
using JsonApiDotNetCore.Queries.Parsing;
8+
using JsonApiDotNetCore.QueryStrings;
9+
using JsonApiDotNetCore.Resources;
10+
using JsonApiDotNetCore.Serialization.Objects;
11+
using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters;
12+
using TestBuildingBlocks;
13+
using Xunit;
14+
15+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength;
16+
17+
public sealed class LengthFilterParseTests : BaseParseTests
18+
{
19+
private readonly FilterQueryStringParameterReader _reader;
20+
21+
public LengthFilterParseTests()
22+
{
23+
var resourceFactory = new ResourceFactory(new ServiceContainer());
24+
var scopeParser = new QueryStringParameterScopeParser();
25+
var valueParser = new LengthFilterParser(resourceFactory);
26+
27+
_reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options);
28+
}
29+
30+
[Theory]
31+
[InlineData("filter", "equals(length^", "( expected.")]
32+
[InlineData("filter", "equals(length(^", "Field name expected.")]
33+
[InlineData("filter", "equals(length(^ ", "Unexpected whitespace.")]
34+
[InlineData("filter", "equals(length(^)", "Field name expected.")]
35+
[InlineData("filter", "equals(length(^'a')", "Field name expected.")]
36+
[InlineData("filter", "equals(length(^some)", "Field 'some' does not exist on resource type 'blogs'.")]
37+
[InlineData("filter", "equals(length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")]
38+
[InlineData("filter", "equals(length(^null)", "Field name expected.")]
39+
[InlineData("filter", "equals(length(title)^)", ", expected.")]
40+
[InlineData("filter", "equals(length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")]
41+
public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage)
42+
{
43+
// Arrange
44+
var parameterValueSource = new MarkedText(parameterValue, '^');
45+
46+
// Act
47+
Action action = () => _reader.Read(parameterName, parameterValueSource.Text);
48+
49+
// Assert
50+
InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
51+
52+
exception.ParameterName.Should().Be(parameterName);
53+
exception.Errors.ShouldHaveCount(1);
54+
55+
ErrorObject error = exception.Errors[0];
56+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
57+
error.Title.Should().Be("The specified filter is invalid.");
58+
error.Detail.Should().Be($"{errorMessage} {parameterValueSource}");
59+
error.Source.ShouldNotBeNull();
60+
error.Source.Parameter.Should().Be(parameterName);
61+
}
62+
63+
[Theory]
64+
[InlineData("filter", "equals(length(title),'1')", null)]
65+
[InlineData("filter", "greaterThan(length(owner.userName),'1')", null)]
66+
[InlineData("filter", "has(posts,lessThan(length(author.userName),'1'))", null)]
67+
[InlineData("filter", "or(equals(length(title),'1'),equals(length(platformName),'1'))", null)]
68+
[InlineData("filter[posts]", "equals(length(author.userName),'1')", "posts")]
69+
public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected)
70+
{
71+
// Act
72+
_reader.Read(parameterName, parameterValue);
73+
74+
IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints();
75+
76+
// Assert
77+
ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single();
78+
scope?.ToString().Should().Be(scopeExpected);
79+
80+
QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single();
81+
value.ToString().Should().Be(parameterValue);
82+
}
83+
}

0 commit comments

Comments
 (0)