Skip to content

Commit 692d827

Browse files
committed
Add example for 'isUpperCase' custom filter function
1 parent e4e5292 commit 692d827

File tree

5 files changed

+361
-0
lines changed

5 files changed

+361
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Text;
2+
using JsonApiDotNetCore;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase;
6+
7+
/// <summary>
8+
/// Represents the "isUpperCase" filter function, resulting from text such as: isUpperCase(title)
9+
/// </summary>
10+
internal sealed class IsUpperCaseExpression : FilterExpression
11+
{
12+
public const string Keyword = "isUpperCase";
13+
14+
public ResourceFieldChainExpression TargetAttribute { get; }
15+
16+
public IsUpperCaseExpression(ResourceFieldChainExpression targetAttribute)
17+
{
18+
ArgumentGuard.NotNull(targetAttribute);
19+
20+
TargetAttribute = targetAttribute;
21+
}
22+
23+
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
24+
{
25+
return visitor.DefaultVisit(this, argument);
26+
}
27+
28+
public override string ToString()
29+
{
30+
return InnerToString(false);
31+
}
32+
33+
public override string ToFullString()
34+
{
35+
return InnerToString(true);
36+
}
37+
38+
private string InnerToString(bool toFullString)
39+
{
40+
var builder = new StringBuilder();
41+
42+
builder.Append(Keyword);
43+
builder.Append('(');
44+
builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute);
45+
builder.Append(')');
46+
47+
return builder.ToString();
48+
}
49+
50+
public override bool Equals(object? obj)
51+
{
52+
if (ReferenceEquals(this, obj))
53+
{
54+
return true;
55+
}
56+
57+
if (obj is null || GetType() != obj.GetType())
58+
{
59+
return false;
60+
}
61+
62+
var other = (IsUpperCaseExpression)obj;
63+
64+
return TargetAttribute.Equals(other.TargetAttribute);
65+
}
66+
67+
public override int GetHashCode()
68+
{
69+
return TargetAttribute.GetHashCode();
70+
}
71+
}
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.IsUpperCase;
16+
17+
public sealed class IsUpperCaseFilterParseTests : BaseParseTests
18+
{
19+
private readonly FilterQueryStringParameterReader _reader;
20+
21+
public IsUpperCaseFilterParseTests()
22+
{
23+
var resourceFactory = new ResourceFactory(new ServiceContainer());
24+
var scopeParser = new QueryStringParameterScopeParser();
25+
var valueParser = new IsUpperCaseFilterParser(resourceFactory, Enumerable.Empty<IFilterValueConverter>());
26+
27+
_reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options);
28+
}
29+
30+
[Theory]
31+
[InlineData("filter", "isUpperCase^", "( expected.")]
32+
[InlineData("filter", "isUpperCase(^", "Field name expected.")]
33+
[InlineData("filter", "isUpperCase(^ ", "Unexpected whitespace.")]
34+
[InlineData("filter", "isUpperCase(^)", "Field name expected.")]
35+
[InlineData("filter", "isUpperCase(^'a')", "Field name expected.")]
36+
[InlineData("filter", "isUpperCase(^some)", "Field 'some' does not exist on resource type 'blogs'.")]
37+
[InlineData("filter", "isUpperCase(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")]
38+
[InlineData("filter", "isUpperCase(^null)", "Field name expected.")]
39+
[InlineData("filter", "isUpperCase(title)^)", "End of expression expected.")]
40+
[InlineData("filter", "isUpperCase(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", "isUpperCase(title)", null)]
65+
[InlineData("filter", "isUpperCase(owner.userName)", null)]
66+
[InlineData("filter", "has(posts,isUpperCase(author.userName))", null)]
67+
[InlineData("filter", "or(isUpperCase(title),isUpperCase(platformName))", null)]
68+
[InlineData("filter[posts]", "isUpperCase(author.userName)", "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+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using JsonApiDotNetCore.Queries.Expressions;
2+
using JsonApiDotNetCore.Queries.Parsing;
3+
using JsonApiDotNetCore.QueryStrings;
4+
using JsonApiDotNetCore.QueryStrings.FieldChains;
5+
using JsonApiDotNetCore.Resources;
6+
using JsonApiDotNetCore.Resources.Annotations;
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase;
9+
10+
internal sealed class IsUpperCaseFilterParser : FilterParser
11+
{
12+
public IsUpperCaseFilterParser(IResourceFactory resourceFactory, IEnumerable<IFilterValueConverter> filterValueConverters)
13+
: base(resourceFactory, filterValueConverters)
14+
{
15+
}
16+
17+
protected override FilterExpression ParseFilter()
18+
{
19+
if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: IsUpperCaseExpression.Keyword })
20+
{
21+
return ParseIsUpperCase();
22+
}
23+
24+
return base.ParseFilter();
25+
}
26+
27+
private IsUpperCaseExpression ParseIsUpperCase()
28+
{
29+
EatText(IsUpperCaseExpression.Keyword);
30+
EatSingleCharacterToken(TokenKind.OpenParen);
31+
32+
int chainStartPosition = GetNextTokenPositionOrEnd();
33+
34+
ResourceFieldChainExpression targetAttributeChain =
35+
ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null);
36+
37+
ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1];
38+
39+
if (attribute.Property.PropertyType != typeof(string))
40+
{
41+
int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain);
42+
throw new QueryParseException("Attribute of type 'String' expected.", position);
43+
}
44+
45+
EatSingleCharacterToken(TokenKind.CloseParen);
46+
47+
return new IsUpperCaseExpression(targetAttributeChain);
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Net;
2+
using FluentAssertions;
3+
using JsonApiDotNetCore.Queries.Parsing;
4+
using JsonApiDotNetCore.Queries.QueryableBuilding;
5+
using JsonApiDotNetCore.Serialization.Objects;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using TestBuildingBlocks;
8+
using Xunit;
9+
10+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase;
11+
12+
public sealed class IsUpperCaseFilterTests : IClassFixture<IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext>>
13+
{
14+
private readonly IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> _testContext;
15+
private readonly QueryStringFakers _fakers = new();
16+
17+
public IsUpperCaseFilterTests(IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> testContext)
18+
{
19+
_testContext = testContext;
20+
21+
testContext.UseController<BlogsController>();
22+
23+
testContext.ConfigureServicesAfterStartup(services =>
24+
{
25+
services.AddTransient<IFilterParser, IsUpperCaseFilterParser>();
26+
services.AddTransient<IWhereClauseBuilder, IsUpperCaseWhereClauseBuilder>();
27+
});
28+
}
29+
30+
[Fact]
31+
public async Task Can_filter_casing_at_primary_endpoint()
32+
{
33+
// Arrange
34+
List<Blog> blogs = _fakers.Blog.Generate(2);
35+
36+
blogs[0].Title = blogs[0].Title.ToLowerInvariant();
37+
blogs[1].Title = blogs[1].Title.ToUpperInvariant();
38+
39+
await _testContext.RunOnDatabaseAsync(async dbContext =>
40+
{
41+
await dbContext.ClearTableAsync<Blog>();
42+
dbContext.Blogs.AddRange(blogs);
43+
await dbContext.SaveChangesAsync();
44+
});
45+
46+
const string route = "/blogs?filter=isUpperCase(title)";
47+
48+
// Act
49+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
50+
51+
// Assert
52+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
53+
54+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
55+
responseDocument.Data.ManyValue[0].Type.Should().Be("blogs");
56+
responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId);
57+
}
58+
59+
[Fact]
60+
public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint()
61+
{
62+
// Arrange
63+
Blog blog = _fakers.Blog.Generate();
64+
blog.Posts = _fakers.BlogPost.Generate(3);
65+
66+
blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant();
67+
blog.Posts[0].Url = blog.Posts[0].Url.ToUpperInvariant();
68+
69+
blog.Posts[1].Caption = blog.Posts[1].Caption.ToUpperInvariant();
70+
blog.Posts[1].Url = blog.Posts[1].Url.ToLowerInvariant();
71+
72+
blog.Posts[2].Caption = blog.Posts[2].Caption.ToLowerInvariant();
73+
blog.Posts[2].Url = blog.Posts[2].Url.ToLowerInvariant();
74+
75+
await _testContext.RunOnDatabaseAsync(async dbContext =>
76+
{
77+
dbContext.Blogs.Add(blog);
78+
await dbContext.SaveChangesAsync();
79+
});
80+
81+
string route = $"/blogs/{blog.StringId}/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))";
82+
83+
// Act
84+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
85+
86+
// Assert
87+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
88+
89+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
90+
responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts");
91+
responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId);
92+
}
93+
94+
[Fact]
95+
public async Task Can_filter_casing_in_included_resources()
96+
{
97+
// Arrange
98+
List<Blog> blogs = _fakers.Blog.Generate(2);
99+
blogs[0].Title = blogs[0].Title.ToLowerInvariant();
100+
blogs[1].Title = blogs[1].Title.ToUpperInvariant();
101+
102+
blogs[1].Posts = _fakers.BlogPost.Generate(2);
103+
blogs[1].Posts[0].Caption = blogs[1].Posts[0].Caption.ToLowerInvariant();
104+
blogs[1].Posts[1].Caption = blogs[1].Posts[1].Caption.ToUpperInvariant();
105+
106+
await _testContext.RunOnDatabaseAsync(async dbContext =>
107+
{
108+
await dbContext.ClearTableAsync<Blog>();
109+
dbContext.Blogs.AddRange(blogs);
110+
await dbContext.SaveChangesAsync();
111+
});
112+
113+
const string route = "/blogs?filter=isUpperCase(title)&include=posts&filter[posts]=isUpperCase(caption)";
114+
115+
// Act
116+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
117+
118+
// Assert
119+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
120+
121+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
122+
responseDocument.Data.ManyValue[0].Type.Should().Be("blogs");
123+
responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId);
124+
125+
responseDocument.Included.ShouldHaveCount(1);
126+
responseDocument.Included[0].Type.Should().Be("blogPosts");
127+
responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId);
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System.Linq.Expressions;
2+
using System.Reflection;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
using JsonApiDotNetCore.Queries.QueryableBuilding;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase;
7+
8+
internal sealed class IsUpperCaseWhereClauseBuilder : WhereClauseBuilder
9+
{
10+
private static readonly MethodInfo ToUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!;
11+
12+
public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context)
13+
{
14+
if (expression is IsUpperCaseExpression isUpperCaseExpression)
15+
{
16+
return VisitIsUpperCase(isUpperCaseExpression, context);
17+
}
18+
19+
return base.DefaultVisit(expression, context);
20+
}
21+
22+
private Expression VisitIsUpperCase(IsUpperCaseExpression expression, QueryClauseBuilderContext context)
23+
{
24+
Expression propertyAccess = Visit(expression.TargetAttribute, context);
25+
MethodCallExpression toUpperMethodCall = Expression.Call(propertyAccess, ToUpperMethod);
26+
27+
return Expression.Equal(propertyAccess, toUpperMethodCall);
28+
}
29+
}

0 commit comments

Comments
 (0)