Skip to content

Commit 32101de

Browse files
author
Bart Koelman
committed
Added support for passing a filter condition as second argument to the has() function.
Example: GET /blogs?filter=has(posts,not(equals(url,null)))
1 parent d033166 commit 32101de

File tree

9 files changed

+211
-15
lines changed

9 files changed

+211
-15
lines changed

docs/usage/reading/filtering.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ Putting it all together, you can build quite complex filters, such as:
7474
GET /blogs?include=owner.articles.revisions&filter=and(or(equals(title,'Technology'),has(owner.articles)),not(equals(owner.lastName,null)))&filter[owner.articles]=equals(caption,'Two')&filter[owner.articles.revisions]=greaterThan(publishTime,'2005-05-05') HTTP/1.1
7575
```
7676

77+
_since v4.2_
78+
79+
The `has` function takes an optional filter condition as second parameter, for example:
80+
81+
```http
82+
GET /customers?filter=has(orders,not(equals(status,'Paid'))) HTTP/1.1
83+
```
84+
85+
Which returns only customers that have at least one unpaid order.
86+
7787
# Legacy filters
7888

7989
The next section describes how filtering worked in versions prior to v4.0. They are always applied on the set of resources being requested (no nesting).

src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
1+
using System;
2+
using System.Text;
13
using JetBrains.Annotations;
24
using JsonApiDotNetCore.Queries.Internal.Parsing;
35

46
namespace JsonApiDotNetCore.Queries.Expressions
57
{
68
/// <summary>
7-
/// Represents the "has" filter function, resulting from text such as: has(articles)
9+
/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false'))
810
/// </summary>
911
[PublicAPI]
1012
public class CollectionNotEmptyExpression : FilterExpression
1113
{
1214
public ResourceFieldChainExpression TargetCollection { get; }
15+
public FilterExpression Filter { get; }
1316

14-
public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection)
17+
public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection, FilterExpression filter)
1518
{
1619
ArgumentGuard.NotNull(targetCollection, nameof(targetCollection));
1720

1821
TargetCollection = targetCollection;
22+
Filter = filter;
1923
}
2024

2125
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
@@ -25,7 +29,20 @@ public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgum
2529

2630
public override string ToString()
2731
{
28-
return $"{Keywords.Has}({TargetCollection})";
32+
var builder = new StringBuilder();
33+
builder.Append(Keywords.Has);
34+
builder.Append('(');
35+
builder.Append(TargetCollection);
36+
37+
if (Filter != null)
38+
{
39+
builder.Append(',');
40+
builder.Append(Filter);
41+
}
42+
43+
builder.Append(')');
44+
45+
return builder.ToString();
2946
}
3047

3148
public override bool Equals(object obj)
@@ -42,12 +59,12 @@ public override bool Equals(object obj)
4259

4360
var other = (CollectionNotEmptyExpression)obj;
4461

45-
return TargetCollection.Equals(other.TargetCollection);
62+
return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter);
4663
}
4764

4865
public override int GetHashCode()
4966
{
50-
return TargetCollection.GetHashCode();
67+
return HashCode.Combine(TargetCollection, Filter);
5168
}
5269
}
5370
}

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ public override QueryExpression VisitCollectionNotEmpty(CollectionNotEmptyExpres
9393
{
9494
if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection)
9595
{
96-
var newExpression = new CollectionNotEmptyExpression(newTargetCollection);
96+
FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null;
97+
98+
var newExpression = new CollectionNotEmptyExpression(newTargetCollection, newFilter);
9799
return newExpression.Equals(expression) ? expression : newExpression;
98100
}
99101
}

src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing
1414
[PublicAPI]
1515
public class FilterParser : QueryExpressionParser
1616
{
17+
private readonly IResourceContextProvider _resourceContextProvider;
1718
private readonly IResourceFactory _resourceFactory;
1819
private readonly Action<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback;
1920
private ResourceContext _resourceContextInScope;
@@ -22,8 +23,10 @@ public FilterParser(IResourceContextProvider resourceContextProvider, IResourceF
2223
Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null)
2324
: base(resourceContextProvider)
2425
{
26+
ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider));
2527
ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory));
2628

29+
_resourceContextProvider = resourceContextProvider;
2730
_resourceFactory = resourceFactory;
2831
_validateSingleFieldCallback = validateSingleFieldCallback;
2932
}
@@ -234,10 +237,31 @@ protected CollectionNotEmptyExpression ParseHas()
234237
EatSingleCharacterToken(TokenKind.OpenParen);
235238

236239
ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null);
240+
FilterExpression filter = null;
241+
242+
if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma)
243+
{
244+
EatSingleCharacterToken(TokenKind.Comma);
245+
246+
filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields.Last());
247+
}
237248

238249
EatSingleCharacterToken(TokenKind.CloseParen);
239250

240-
return new CollectionNotEmptyExpression(targetCollection);
251+
return new CollectionNotEmptyExpression(targetCollection, filter);
252+
}
253+
254+
private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship)
255+
{
256+
ResourceContext outerScopeBackup = _resourceContextInScope;
257+
258+
Type innerResourceType = hasManyRelationship.RightType;
259+
_resourceContextInScope = _resourceContextProvider.GetResourceContext(innerResourceType);
260+
261+
FilterExpression filter = ParseFilter();
262+
263+
_resourceContextInScope = outerScopeBackup;
264+
return filter;
241265
}
242266

243267
protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements)

src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ protected virtual Expression ApplyFilter(Expression source, FilterExpression fil
9393
{
9494
using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType);
9595

96-
var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType);
96+
var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory);
9797
return builder.ApplyWhere(filter);
9898
}
9999

src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,35 @@ public class WhereClauseBuilder : QueryClauseBuilder<Type>
2323

2424
private readonly Expression _source;
2525
private readonly Type _extensionType;
26+
private readonly LambdaParameterNameFactory _nameFactory;
2627

27-
public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType)
28+
public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory)
2829
: base(lambdaScope)
2930
{
3031
ArgumentGuard.NotNull(source, nameof(source));
3132
ArgumentGuard.NotNull(extensionType, nameof(extensionType));
33+
ArgumentGuard.NotNull(nameFactory, nameof(nameFactory));
3234

3335
_source = source;
3436
_extensionType = extensionType;
37+
_nameFactory = nameFactory;
3538
}
3639

3740
public Expression ApplyWhere(FilterExpression filter)
3841
{
3942
ArgumentGuard.NotNull(filter, nameof(filter));
4043

41-
Expression body = Visit(filter, null);
42-
LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter);
44+
LambdaExpression lambda = GetPredicateLambda(filter);
4345

4446
return WhereExtensionMethodCall(lambda);
4547
}
4648

49+
private LambdaExpression GetPredicateLambda(FilterExpression filter)
50+
{
51+
Expression body = Visit(filter, null);
52+
return Expression.Lambda(body, LambdaScope.Parameter);
53+
}
54+
4755
private Expression WhereExtensionMethodCall(LambdaExpression predicate)
4856
{
4957
return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate);
@@ -60,11 +68,28 @@ public override Expression VisitCollectionNotEmpty(CollectionNotEmptyExpression
6068
throw new InvalidOperationException("Expression must be a collection.");
6169
}
6270

63-
return AnyExtensionMethodCall(elementType, property);
71+
Expression predicate = null;
72+
73+
if (expression.Filter != null)
74+
{
75+
var hasManyThrough = expression.TargetCollection.Fields.Last() as HasManyThroughAttribute;
76+
var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough);
77+
using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType);
78+
79+
var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory);
80+
predicate = builder.GetPredicateLambda(expression.Filter);
81+
}
82+
83+
return AnyExtensionMethodCall(elementType, property, predicate);
6484
}
6585

66-
private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source)
86+
private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression predicate)
6787
{
88+
if (predicate != null)
89+
{
90+
return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate);
91+
}
92+
6893
return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source);
6994
}
7095

@@ -276,6 +301,8 @@ protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadO
276301

277302
private static string GetPropertyName(ResourceFieldAttribute field)
278303
{
304+
// TODO: Is this still true when using has() with a filter?
305+
279306
// In case of a HasManyThrough access (from count() or has() function), we only need to look at the number of entries in the join table.
280307
return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name;
281308
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,35 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
202202
responseDocument.ManyData[0].Id.Should().Be(blogs[1].StringId);
203203
}
204204

205+
[Fact]
206+
public async Task Can_filter_on_HasMany_relationship_with_nested_condition()
207+
{
208+
// Arrange
209+
List<Blog> blogs = _fakers.Blog.Generate(2);
210+
blogs[0].Posts = _fakers.BlogPost.Generate(1);
211+
blogs[1].Posts = _fakers.BlogPost.Generate(1);
212+
blogs[1].Posts[0].Comments = _fakers.Comment.Generate(1).ToHashSet();
213+
blogs[1].Posts[0].Comments.ElementAt(0).Text = "ABC";
214+
215+
await _testContext.RunOnDatabaseAsync(async dbContext =>
216+
{
217+
await dbContext.ClearTableAsync<Blog>();
218+
dbContext.Blogs.AddRange(blogs);
219+
await dbContext.SaveChangesAsync();
220+
});
221+
222+
const string route = "/blogs?filter=has(posts,has(comments,startsWith(text,'A')))";
223+
224+
// Act
225+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
226+
227+
// Assert
228+
httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
229+
230+
responseDocument.ManyData.Should().HaveCount(1);
231+
responseDocument.ManyData[0].Id.Should().Be(blogs[1].StringId);
232+
}
233+
205234
[Fact]
206235
public async Task Can_filter_on_HasManyThrough_relationship()
207236
{
@@ -235,6 +264,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
235264
responseDocument.ManyData[0].Id.Should().Be(posts[1].StringId);
236265
}
237266

267+
[Fact]
268+
public async Task Can_filter_on_HasManyThrough_relationship_with_nested_condition()
269+
{
270+
// Arrange
271+
List<Blog> blogs = _fakers.Blog.Generate(2);
272+
blogs[0].Posts = _fakers.BlogPost.Generate(1);
273+
blogs[1].Posts = _fakers.BlogPost.Generate(1);
274+
275+
blogs[1].Posts[0].BlogPostLabels = new HashSet<BlogPostLabel>
276+
{
277+
new BlogPostLabel
278+
{
279+
Label = new Label
280+
{
281+
Color = LabelColor.Green
282+
}
283+
}
284+
};
285+
286+
await _testContext.RunOnDatabaseAsync(async dbContext =>
287+
{
288+
await dbContext.ClearTableAsync<Blog>();
289+
dbContext.Blogs.AddRange(blogs);
290+
await dbContext.SaveChangesAsync();
291+
});
292+
293+
const string route = "/blogs?filter=has(posts,has(labels,equals(color,'Green')))";
294+
295+
// Act
296+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
297+
298+
// Assert
299+
httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
300+
301+
responseDocument.ManyData.Should().HaveCount(1);
302+
responseDocument.ManyData[0].Id.Should().Be(blogs[1].StringId);
303+
}
304+
238305
[Fact]
239306
public async Task Can_filter_in_scope_of_HasMany_relationship()
240307
{

test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,53 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
489489
responseDocument.ManyData[0].Id.Should().Be(resource.StringId);
490490
}
491491

492+
[Fact]
493+
public async Task Can_filter_on_has_with_nested_condition()
494+
{
495+
// Arrange
496+
var resources = new List<FilterableResource>
497+
{
498+
new FilterableResource
499+
{
500+
Children = new List<FilterableResource>
501+
{
502+
new FilterableResource
503+
{
504+
SomeBoolean = false
505+
}
506+
}
507+
},
508+
new FilterableResource
509+
{
510+
Children = new List<FilterableResource>
511+
{
512+
new FilterableResource
513+
{
514+
SomeBoolean = true
515+
}
516+
}
517+
}
518+
};
519+
520+
await _testContext.RunOnDatabaseAsync(async dbContext =>
521+
{
522+
await dbContext.ClearTableAsync<FilterableResource>();
523+
dbContext.FilterableResources.AddRange(resources);
524+
await dbContext.SaveChangesAsync();
525+
});
526+
527+
const string route = "/filterableResources?filter=has(children,equals(someBoolean,'true'))";
528+
529+
// Act
530+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
531+
532+
// Assert
533+
httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
534+
535+
responseDocument.ManyData.Should().HaveCount(1);
536+
responseDocument.ManyData[0].Id.Should().Be(resources[1].StringId);
537+
}
538+
492539
[Fact]
493540
public async Task Can_filter_on_count()
494541
{

test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/FilterParseTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled,
7878
[InlineData("filter", "equals(null", "Field 'null' does not exist on resource 'blogs'.")]
7979
[InlineData("filter", "equals(title,(", "Count function, value between quotes, null or field name expected.")]
8080
[InlineData("filter", "equals(has(posts),'true')", "Field 'has' does not exist on resource 'blogs'.")]
81+
[InlineData("filter", "has(posts,", "Filter function expected.")]
8182
[InlineData("filter", "contains)", "( expected.")]
8283
[InlineData("filter", "contains(title,'a','b')", ") expected.")]
8384
[InlineData("filter", "contains(title,null)", "Value between quotes expected.")]
@@ -125,14 +126,15 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
125126
[InlineData("filter[posts.comments]", "greaterThan(createdAt,'2000-01-01')", "posts.comments", "greaterThan(createdAt,'2000-01-01')")]
126127
[InlineData("filter[posts.comments]", "greaterOrEqual(createdAt,'2000-01-01')", "posts.comments", "greaterOrEqual(createdAt,'2000-01-01')")]
127128
[InlineData("filter", "has(posts)", null, "has(posts)")]
129+
[InlineData("filter", "has(posts,not(equals(url,null)))", null, "has(posts,not(equals(url,null)))")]
128130
[InlineData("filter", "contains(title,'this')", null, "contains(title,'this')")]
129131
[InlineData("filter", "startsWith(title,'this')", null, "startsWith(title,'this')")]
130132
[InlineData("filter", "endsWith(title,'this')", null, "endsWith(title,'this')")]
131133
[InlineData("filter", "any(title,'this','that','there')", null, "any(title,'this','that','there')")]
132134
[InlineData("filter", "and(contains(title,'sales'),contains(title,'marketing'),contains(title,'advertising'))", null,
133135
"and(contains(title,'sales'),contains(title,'marketing'),contains(title,'advertising'))")]
134-
[InlineData("filter[posts]", "or(and(not(equals(author.userName,null)),not(equals(author.displayName,null))),not(has(comments)))", "posts",
135-
"or(and(not(equals(author.userName,null)),not(equals(author.displayName,null))),not(has(comments)))")]
136+
[InlineData("filter[posts]", "or(and(not(equals(author.userName,null)),not(equals(author.displayName,null))),not(has(comments,startsWith(text,'A'))))",
137+
"posts", "or(and(not(equals(author.userName,null)),not(equals(author.displayName,null))),not(has(comments,startsWith(text,'A'))))")]
136138
public void Reader_Read_Succeeds(string parameterName, string parameterValue, string scopeExpected, string valueExpected)
137139
{
138140
// Act

0 commit comments

Comments
 (0)