Skip to content

Filters: allow has() with a condition #986

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion docs/usage/reading/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ Putting it all together, you can build quite complex filters, such as:
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
```

_since v4.2_

The `has` function takes an optional filter condition as second parameter, for example:

```http
GET /customers?filter=has(orders,not(equals(status,'Paid'))) HTTP/1.1
```

Which returns only customers that have at least one unpaid order.

# Legacy filters

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

## Custom Filters
# Custom Filters

There are multiple ways you can add custom filters:

Expand All @@ -124,3 +134,62 @@ There are multiple ways you can add custom filters:
3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator
4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable<T>` expression just before execution
5. Take a deep dive and plug into reader/parser/tokenizer/visitor/builder for adding additional general-purpose filter operators

# Filter syntax

For reference, we provide the EBNF grammar for filter expressions below (in [ANTLR4](https://github.com/antlr/antlr4) style):

```ebnf
grammar Filter;

filterExpression:
notExpression
| logicalExpression
| comparisonExpression
| matchTextExpression
| anyExpression
| hasExpression;

notExpression:
'not' LPAREN filterExpression RPAREN;

logicalExpression:
( 'and' | 'or' ) LPAREN filterExpression ( COMMA filterExpression )* RPAREN;

comparisonExpression:
( 'equals' | 'greaterThan' | 'greaterOrEqual' | 'lessThan' | 'lessOrEqual' ) LPAREN (
countExpression | fieldChain
) COMMA (
countExpression | literalConstant | 'null' | fieldChain
) RPAREN;

matchTextExpression:
( 'contains' | 'startsWith' | 'endsWith' ) LPAREN fieldChain COMMA literalConstant RPAREN;

anyExpression:
'any' LPAREN fieldChain COMMA literalConstant ( COMMA literalConstant )+ RPAREN;

hasExpression:
'has' LPAREN fieldChain ( COMMA filterExpression )? RPAREN;

countExpression:
'count' LPAREN fieldChain RPAREN;

fieldChain:
FIELD ( '.' FIELD )*;

literalConstant:
ESCAPED_TEXT;

LPAREN: '(';
RPAREN: ')';
COMMA: ',';

fragment OUTER_FIELD_CHARACTER: [A-Za-z0-9];
fragment INNER_FIELD_CHARACTER: [A-Za-z0-9_-];
FIELD: OUTER_FIELD_CHARACTER ( INNER_FIELD_CHARACTER* OUTER_FIELD_CHARACTER )?;

ESCAPED_TEXT: '\'' ( ~['] | '\'\'' )* '\'' ;

LINE_BREAKS: [\r\n]+ -> skip;
```
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
using System;
using System.Text;
using JetBrains.Annotations;
using JsonApiDotNetCore.Queries.Internal.Parsing;

namespace JsonApiDotNetCore.Queries.Expressions
{
/// <summary>
/// Represents the "has" filter function, resulting from text such as: has(articles)
/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false'))
/// </summary>
[PublicAPI]
public class CollectionNotEmptyExpression : FilterExpression
{
public ResourceFieldChainExpression TargetCollection { get; }
public FilterExpression Filter { get; }

public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection)
public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection, FilterExpression filter)
{
ArgumentGuard.NotNull(targetCollection, nameof(targetCollection));

TargetCollection = targetCollection;
Filter = filter;
}

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

public override string ToString()
{
return $"{Keywords.Has}({TargetCollection})";
var builder = new StringBuilder();
builder.Append(Keywords.Has);
builder.Append('(');
builder.Append(TargetCollection);

if (Filter != null)
{
builder.Append(',');
builder.Append(Filter);
}

builder.Append(')');

return builder.ToString();
}

public override bool Equals(object obj)
Expand All @@ -42,12 +59,12 @@ public override bool Equals(object obj)

var other = (CollectionNotEmptyExpression)obj;

return TargetCollection.Equals(other.TargetCollection);
return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter);
}

public override int GetHashCode()
{
return TargetCollection.GetHashCode();
return HashCode.Combine(TargetCollection, Filter);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace JsonApiDotNetCore.Queries.Expressions
{
/// <summary>
/// Represents the base type for filter functions.
/// Represents the base type for filter functions that return a boolean value.
/// </summary>
public abstract class FilterExpression : FunctionExpression
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace JsonApiDotNetCore.Queries.Expressions
{
/// <summary>
/// Represents the base type for functions.
/// Represents the base type for functions that return a value.
/// </summary>
public abstract class FunctionExpression : QueryExpression
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ namespace JsonApiDotNetCore.Queries.Expressions
public class LogicalExpression : FilterExpression
{
public LogicalOperator Operator { get; }
public IReadOnlyCollection<QueryExpression> Terms { get; }
public IReadOnlyCollection<FilterExpression> Terms { get; }

public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection<QueryExpression> terms)
public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection<FilterExpression> terms)
{
ArgumentGuard.NotNull(terms, nameof(terms));

Expand Down
4 changes: 2 additions & 2 deletions src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ namespace JsonApiDotNetCore.Queries.Expressions
[PublicAPI]
public class NotExpression : FilterExpression
{
public QueryExpression Child { get; }
public FilterExpression Child { get; }

public NotExpression(QueryExpression child)
public NotExpression(FilterExpression child)
{
ArgumentGuard.NotNull(child, nameof(child));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public override QueryExpression VisitLogical(LogicalExpression expression, TArgu
{
if (expression != null)
{
IReadOnlyCollection<QueryExpression> newTerms = VisitSequence(expression.Terms, argument);
IReadOnlyCollection<FilterExpression> newTerms = VisitSequence(expression.Terms, argument);

if (newTerms.Count == 1)
{
Expand All @@ -75,9 +75,7 @@ public override QueryExpression VisitNot(NotExpression expression, TArgument arg
{
if (expression != null)
{
QueryExpression newChild = Visit(expression.Child, argument);

if (newChild != null)
if (Visit(expression.Child, argument) is FilterExpression newChild)
{
var newExpression = new NotExpression(newChild);
return newExpression.Equals(expression) ? expression : newExpression;
Expand All @@ -93,7 +91,9 @@ public override QueryExpression VisitCollectionNotEmpty(CollectionNotEmptyExpres
{
if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection)
{
var newExpression = new CollectionNotEmptyExpression(newTargetCollection);
FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null;

var newExpression = new CollectionNotEmptyExpression(newTargetCollection, newFilter);
return newExpression.Equals(expression) ? expression : newExpression;
}
}
Expand Down
28 changes: 26 additions & 2 deletions src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing
[PublicAPI]
public class FilterParser : QueryExpressionParser
{
private readonly IResourceContextProvider _resourceContextProvider;
private readonly IResourceFactory _resourceFactory;
private readonly Action<ResourceFieldAttribute, ResourceContext, string> _validateSingleFieldCallback;
private ResourceContext _resourceContextInScope;
Expand All @@ -22,8 +23,10 @@ public FilterParser(IResourceContextProvider resourceContextProvider, IResourceF
Action<ResourceFieldAttribute, ResourceContext, string> validateSingleFieldCallback = null)
: base(resourceContextProvider)
{
ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider));
ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory));

_resourceContextProvider = resourceContextProvider;
_resourceFactory = resourceFactory;
_validateSingleFieldCallback = validateSingleFieldCallback;
}
Expand Down Expand Up @@ -103,7 +106,7 @@ protected LogicalExpression ParseLogical(string operatorName)
EatText(operatorName);
EatSingleCharacterToken(TokenKind.OpenParen);

var terms = new List<QueryExpression>();
var terms = new List<FilterExpression>();

FilterExpression term = ParseFilter();
terms.Add(term);
Expand Down Expand Up @@ -234,10 +237,31 @@ protected CollectionNotEmptyExpression ParseHas()
EatSingleCharacterToken(TokenKind.OpenParen);

ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null);
FilterExpression filter = null;

if (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma)
{
EatSingleCharacterToken(TokenKind.Comma);

filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields.Last());
}

EatSingleCharacterToken(TokenKind.CloseParen);

return new CollectionNotEmptyExpression(targetCollection);
return new CollectionNotEmptyExpression(targetCollection, filter);
}

private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship)
{
ResourceContext outerScopeBackup = _resourceContextInScope;

Type innerResourceType = hasManyRelationship.RightType;
_resourceContextInScope = _resourceContextProvider.GetResourceContext(innerResourceType);

FilterExpression filter = ParseFilter();

_resourceContextInScope = outerScopeBackup;
return filter;
}

protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ protected virtual Expression ApplyFilter(Expression source, FilterExpression fil
{
using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType);

var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType);
var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory);
return builder.ApplyWhere(filter);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,35 @@ public class WhereClauseBuilder : QueryClauseBuilder<Type>

private readonly Expression _source;
private readonly Type _extensionType;
private readonly LambdaParameterNameFactory _nameFactory;

public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType)
public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory)
: base(lambdaScope)
{
ArgumentGuard.NotNull(source, nameof(source));
ArgumentGuard.NotNull(extensionType, nameof(extensionType));
ArgumentGuard.NotNull(nameFactory, nameof(nameFactory));

_source = source;
_extensionType = extensionType;
_nameFactory = nameFactory;
}

public Expression ApplyWhere(FilterExpression filter)
{
ArgumentGuard.NotNull(filter, nameof(filter));

Expression body = Visit(filter, null);
LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter);
LambdaExpression lambda = GetPredicateLambda(filter);

return WhereExtensionMethodCall(lambda);
}

private LambdaExpression GetPredicateLambda(FilterExpression filter)
{
Expression body = Visit(filter, null);
return Expression.Lambda(body, LambdaScope.Parameter);
}

private Expression WhereExtensionMethodCall(LambdaExpression predicate)
{
return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate);
Expand All @@ -60,11 +68,28 @@ public override Expression VisitCollectionNotEmpty(CollectionNotEmptyExpression
throw new InvalidOperationException("Expression must be a collection.");
}

return AnyExtensionMethodCall(elementType, property);
Expression predicate = null;

if (expression.Filter != null)
{
var hasManyThrough = expression.TargetCollection.Fields.Last() as HasManyThroughAttribute;
var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough);
using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType);

var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory);
predicate = builder.GetPredicateLambda(expression.Filter);
}

return AnyExtensionMethodCall(elementType, property, predicate);
}

private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source)
private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression predicate)
{
if (predicate != null)
{
return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate);
}

return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source);
}

Expand Down Expand Up @@ -276,7 +301,6 @@ protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadO

private static string GetPropertyName(ResourceFieldAttribute field)
{
// 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.
return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name;
}
}
Expand Down
Loading