Skip to content

Commit d62327f

Browse files
committed
Add example for 'sum' custom filter function
1 parent 58564ec commit d62327f

File tree

7 files changed

+487
-1
lines changed

7 files changed

+487
-1
lines changed

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ public sealed class Comment : Identifiable<int>
1414
[Attr]
1515
public DateTime CreatedAt { get; set; }
1616

17+
[Attr]
18+
public int NumStars { get; set; }
19+
1720
[HasOne]
1821
public WebAccount? Author { get; set; }
1922

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.Text;
2+
using JsonApiDotNetCore;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum;
6+
7+
/// <summary>
8+
/// Represents the "sum" function, resulting from text such as: sum(orderLines,quantity) or sum(friends,count(children))
9+
/// </summary>
10+
internal sealed class SumExpression : FunctionExpression
11+
{
12+
public const string Keyword = "sum";
13+
14+
public ResourceFieldChainExpression TargetToManyRelationship { get; }
15+
public QueryExpression Selector { get; }
16+
17+
public override Type ReturnType { get; } = typeof(ulong);
18+
19+
public SumExpression(ResourceFieldChainExpression targetToManyRelationship, QueryExpression selector)
20+
{
21+
ArgumentGuard.NotNull(targetToManyRelationship);
22+
ArgumentGuard.NotNull(selector);
23+
24+
TargetToManyRelationship = targetToManyRelationship;
25+
Selector = selector;
26+
}
27+
28+
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
29+
{
30+
return visitor.DefaultVisit(this, argument);
31+
}
32+
33+
public override string ToString()
34+
{
35+
return InnerToString(false);
36+
}
37+
38+
public override string ToFullString()
39+
{
40+
return InnerToString(true);
41+
}
42+
43+
private string InnerToString(bool toFullString)
44+
{
45+
var builder = new StringBuilder();
46+
47+
builder.Append(Keyword);
48+
builder.Append('(');
49+
builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship);
50+
builder.Append(',');
51+
builder.Append(toFullString ? Selector.ToFullString() : Selector);
52+
builder.Append(')');
53+
54+
return builder.ToString();
55+
}
56+
57+
public override bool Equals(object? obj)
58+
{
59+
if (ReferenceEquals(this, obj))
60+
{
61+
return true;
62+
}
63+
64+
if (obj is null || GetType() != obj.GetType())
65+
{
66+
return false;
67+
}
68+
69+
var other = (SumExpression)obj;
70+
71+
return TargetToManyRelationship.Equals(other.TargetToManyRelationship) && Selector.Equals(other.Selector);
72+
}
73+
74+
public override int GetHashCode()
75+
{
76+
return HashCode.Combine(TargetToManyRelationship, Selector);
77+
}
78+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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.Sum;
16+
17+
public sealed class SumFilterParseTests : BaseParseTests
18+
{
19+
private readonly FilterQueryStringParameterReader _reader;
20+
21+
public SumFilterParseTests()
22+
{
23+
var resourceFactory = new ResourceFactory(new ServiceContainer());
24+
var scopeParser = new QueryStringParameterScopeParser();
25+
var valueParser = new SumFilterParser(resourceFactory);
26+
27+
_reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options);
28+
}
29+
30+
[Theory]
31+
[InlineData("filter", "equals(sum^", "( expected.")]
32+
[InlineData("filter", "equals(sum(^", "To-many relationship expected.")]
33+
[InlineData("filter", "equals(sum(^ ", "Unexpected whitespace.")]
34+
[InlineData("filter", "equals(sum(^)", "To-many relationship expected.")]
35+
[InlineData("filter", "equals(sum(^'a')", "To-many relationship expected.")]
36+
[InlineData("filter", "equals(sum(^null)", "To-many relationship expected.")]
37+
[InlineData("filter", "equals(sum(^some)", "Field 'some' does not exist on resource type 'blogs'.")]
38+
[InlineData("filter", "equals(sum(^title)",
39+
"Field chain on resource type 'blogs' failed to match the pattern: a to-many relationship. " +
40+
"To-many relationship on resource type 'blogs' expected.")]
41+
[InlineData("filter", "equals(sum(posts^))", ", expected.")]
42+
[InlineData("filter", "equals(sum(posts,^))", "Field name expected.")]
43+
[InlineData("filter", "equals(sum(posts,author^))",
44+
"Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " +
45+
"To-one relationship or attribute on resource type 'webAccounts' expected.")]
46+
[InlineData("filter", "equals(sum(posts,^url))", "Attribute of a numeric type expected.")]
47+
[InlineData("filter", "equals(sum(posts,^has(labels)))", "Function that returns a numeric type expected.")]
48+
public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage)
49+
{
50+
// Arrange
51+
var parameterValueSource = new MarkedText(parameterValue, '^');
52+
53+
// Act
54+
Action action = () => _reader.Read(parameterName, parameterValueSource.Text);
55+
56+
// Assert
57+
InvalidQueryStringParameterException exception = action.Should().ThrowExactly<InvalidQueryStringParameterException>().And;
58+
59+
exception.ParameterName.Should().Be(parameterName);
60+
exception.Errors.ShouldHaveCount(1);
61+
62+
ErrorObject error = exception.Errors[0];
63+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
64+
error.Title.Should().Be("The specified filter is invalid.");
65+
error.Detail.Should().Be($"{errorMessage} {parameterValueSource}");
66+
error.Source.ShouldNotBeNull();
67+
error.Source.Parameter.Should().Be(parameterName);
68+
}
69+
70+
[Theory]
71+
[InlineData("filter", "has(posts,greaterThan(sum(comments,numStars),'5'))", null)]
72+
[InlineData("filter[posts]", "equals(sum(comments,numStars),'11')", "posts")]
73+
[InlineData("filter[posts]", "equals(sum(labels,count(posts)),'8')", "posts")]
74+
public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected)
75+
{
76+
// Act
77+
_reader.Read(parameterName, parameterValue);
78+
79+
IReadOnlyCollection<ExpressionInScope> constraints = _reader.GetConstraints();
80+
81+
// Assert
82+
ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single();
83+
scope?.ToString().Should().Be(scopeExpected);
84+
85+
QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single();
86+
value.ToString().Should().Be(parameterValue);
87+
}
88+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
using JsonApiDotNetCore.Queries.Parsing;
3+
using JsonApiDotNetCore.QueryStrings.FieldChains;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum;
8+
9+
internal sealed class SumFilterParser : FilterParser
10+
{
11+
private static readonly FieldChainPattern SingleToManyRelationshipChain = FieldChainPattern.Parse("M");
12+
13+
private static readonly HashSet<Type> NumericTypes = new(new[]
14+
{
15+
typeof(sbyte),
16+
typeof(byte),
17+
typeof(short),
18+
typeof(ushort),
19+
typeof(int),
20+
typeof(uint),
21+
typeof(long),
22+
typeof(ulong),
23+
typeof(float),
24+
typeof(double),
25+
typeof(decimal)
26+
});
27+
28+
public SumFilterParser(IResourceFactory resourceFactory)
29+
: base(resourceFactory)
30+
{
31+
}
32+
33+
protected override bool IsFunction(string name)
34+
{
35+
if (name == SumExpression.Keyword)
36+
{
37+
return true;
38+
}
39+
40+
return base.IsFunction(name);
41+
}
42+
43+
protected override FunctionExpression ParseFunction()
44+
{
45+
if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: SumExpression.Keyword })
46+
{
47+
return ParseSum();
48+
}
49+
50+
return base.ParseFunction();
51+
}
52+
53+
private SumExpression ParseSum()
54+
{
55+
EatText(SumExpression.Keyword);
56+
EatSingleCharacterToken(TokenKind.OpenParen);
57+
58+
ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None,
59+
ResourceTypeInScope, "To-many relationship expected.");
60+
61+
EatSingleCharacterToken(TokenKind.Comma);
62+
63+
QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain);
64+
65+
EatSingleCharacterToken(TokenKind.CloseParen);
66+
67+
return new SumExpression(targetToManyRelationshipChain, selector);
68+
}
69+
70+
private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain)
71+
{
72+
var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single();
73+
74+
using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType);
75+
return ParseSumSelector();
76+
}
77+
78+
private QueryExpression ParseSumSelector()
79+
{
80+
int position = GetNextTokenPositionOrEnd();
81+
82+
if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!))
83+
{
84+
FunctionExpression function = ParseFunction();
85+
86+
if (!IsNumericType(function.ReturnType))
87+
{
88+
throw new QueryParseException("Function that returns a numeric type expected.", position);
89+
}
90+
91+
return function;
92+
}
93+
94+
ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None,
95+
ResourceTypeInScope, null);
96+
97+
var attrAttribute = (AttrAttribute)fieldChain.Fields[^1];
98+
99+
if (!IsNumericType(attrAttribute.Property.PropertyType))
100+
{
101+
throw new QueryParseException("Attribute of a numeric type expected.", position);
102+
}
103+
104+
return fieldChain;
105+
}
106+
107+
private static bool IsNumericType(Type type)
108+
{
109+
Type innerType = Nullable.GetUnderlyingType(type) ?? type;
110+
return NumericTypes.Contains(innerType);
111+
}
112+
}

0 commit comments

Comments
 (0)