Skip to content

Commit b387363

Browse files
author
Bart Koelman
authored
Filters: allow has() with a condition (#986)
Adds 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 6be4bae commit b387363

File tree

14 files changed

+282
-31
lines changed

14 files changed

+282
-31
lines changed

docs/usage/reading/filtering.md

Lines changed: 70 additions & 1 deletion
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).
@@ -115,7 +125,7 @@ If you want to use the new filter notation in that case, prefix the parameter va
115125
GET /articles?filter[caption]=tech&filter=expr:equals(caption,'cooking')) HTTP/1.1
116126
```
117127

118-
## Custom Filters
128+
# Custom Filters
119129

120130
There are multiple ways you can add custom filters:
121131

@@ -124,3 +134,62 @@ There are multiple ways you can add custom filters:
124134
3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator
125135
4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable<T>` expression just before execution
126136
5. Take a deep dive and plug into reader/parser/tokenizer/visitor/builder for adding additional general-purpose filter operators
137+
138+
# Filter syntax
139+
140+
For reference, we provide the EBNF grammar for filter expressions below (in [ANTLR4](https://github.com/antlr/antlr4) style):
141+
142+
```ebnf
143+
grammar Filter;
144+
145+
filterExpression:
146+
notExpression
147+
| logicalExpression
148+
| comparisonExpression
149+
| matchTextExpression
150+
| anyExpression
151+
| hasExpression;
152+
153+
notExpression:
154+
'not' LPAREN filterExpression RPAREN;
155+
156+
logicalExpression:
157+
( 'and' | 'or' ) LPAREN filterExpression ( COMMA filterExpression )* RPAREN;
158+
159+
comparisonExpression:
160+
( 'equals' | 'greaterThan' | 'greaterOrEqual' | 'lessThan' | 'lessOrEqual' ) LPAREN (
161+
countExpression | fieldChain
162+
) COMMA (
163+
countExpression | literalConstant | 'null' | fieldChain
164+
) RPAREN;
165+
166+
matchTextExpression:
167+
( 'contains' | 'startsWith' | 'endsWith' ) LPAREN fieldChain COMMA literalConstant RPAREN;
168+
169+
anyExpression:
170+
'any' LPAREN fieldChain COMMA literalConstant ( COMMA literalConstant )+ RPAREN;
171+
172+
hasExpression:
173+
'has' LPAREN fieldChain ( COMMA filterExpression )? RPAREN;
174+
175+
countExpression:
176+
'count' LPAREN fieldChain RPAREN;
177+
178+
fieldChain:
179+
FIELD ( '.' FIELD )*;
180+
181+
literalConstant:
182+
ESCAPED_TEXT;
183+
184+
LPAREN: '(';
185+
RPAREN: ')';
186+
COMMA: ',';
187+
188+
fragment OUTER_FIELD_CHARACTER: [A-Za-z0-9];
189+
fragment INNER_FIELD_CHARACTER: [A-Za-z0-9_-];
190+
FIELD: OUTER_FIELD_CHARACTER ( INNER_FIELD_CHARACTER* OUTER_FIELD_CHARACTER )?;
191+
192+
ESCAPED_TEXT: '\'' ( ~['] | '\'\'' )* '\'' ;
193+
194+
LINE_BREAKS: [\r\n]+ -> skip;
195+
```

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/FilterExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace JsonApiDotNetCore.Queries.Expressions
22
{
33
/// <summary>
4-
/// Represents the base type for filter functions.
4+
/// Represents the base type for filter functions that return a boolean value.
55
/// </summary>
66
public abstract class FilterExpression : FunctionExpression
77
{

src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace JsonApiDotNetCore.Queries.Expressions
22
{
33
/// <summary>
4-
/// Represents the base type for functions.
4+
/// Represents the base type for functions that return a value.
55
/// </summary>
66
public abstract class FunctionExpression : QueryExpression
77
{

src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ namespace JsonApiDotNetCore.Queries.Expressions
1414
public class LogicalExpression : FilterExpression
1515
{
1616
public LogicalOperator Operator { get; }
17-
public IReadOnlyCollection<QueryExpression> Terms { get; }
17+
public IReadOnlyCollection<FilterExpression> Terms { get; }
1818

19-
public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection<QueryExpression> terms)
19+
public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection<FilterExpression> terms)
2020
{
2121
ArgumentGuard.NotNull(terms, nameof(terms));
2222

src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ namespace JsonApiDotNetCore.Queries.Expressions
99
[PublicAPI]
1010
public class NotExpression : FilterExpression
1111
{
12-
public QueryExpression Child { get; }
12+
public FilterExpression Child { get; }
1313

14-
public NotExpression(QueryExpression child)
14+
public NotExpression(FilterExpression child)
1515
{
1616
ArgumentGuard.NotNull(child, nameof(child));
1717

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public override QueryExpression VisitLogical(LogicalExpression expression, TArgu
5454
{
5555
if (expression != null)
5656
{
57-
IReadOnlyCollection<QueryExpression> newTerms = VisitSequence(expression.Terms, argument);
57+
IReadOnlyCollection<FilterExpression> newTerms = VisitSequence(expression.Terms, argument);
5858

5959
if (newTerms.Count == 1)
6060
{
@@ -75,9 +75,7 @@ public override QueryExpression VisitNot(NotExpression expression, TArgument arg
7575
{
7676
if (expression != null)
7777
{
78-
QueryExpression newChild = Visit(expression.Child, argument);
79-
80-
if (newChild != null)
78+
if (Visit(expression.Child, argument) is FilterExpression newChild)
8179
{
8280
var newExpression = new NotExpression(newChild);
8381
return newExpression.Equals(expression) ? expression : newExpression;
@@ -93,7 +91,9 @@ public override QueryExpression VisitCollectionNotEmpty(CollectionNotEmptyExpres
9391
{
9492
if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection)
9593
{
96-
var newExpression = new CollectionNotEmptyExpression(newTargetCollection);
94+
FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null;
95+
96+
var newExpression = new CollectionNotEmptyExpression(newTargetCollection, newFilter);
9797
return newExpression.Equals(expression) ? expression : newExpression;
9898
}
9999
}

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

Lines changed: 26 additions & 2 deletions
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
}
@@ -103,7 +106,7 @@ protected LogicalExpression ParseLogical(string operatorName)
103106
EatText(operatorName);
104107
EatSingleCharacterToken(TokenKind.OpenParen);
105108

106-
var terms = new List<QueryExpression>();
109+
var terms = new List<FilterExpression>();
107110

108111
FilterExpression term = ParseFilter();
109112
terms.Add(term);
@@ -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: 30 additions & 6 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,7 +301,6 @@ protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadO
276301

277302
private static string GetPropertyName(ResourceFieldAttribute field)
278303
{
279-
// 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.
280304
return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name;
281305
}
282306
}

0 commit comments

Comments
 (0)