Skip to content

Commit

Permalink
Added IsEmpty operation for filter predicate (#1176)
Browse files Browse the repository at this point in the history
* Added IsEmpty operation for filter predicate

* small fix

* Allow filter on ismany relationship for isempty operation. Add It test

* Add @ElementCollection to attribute awards

* remove string case

* exclude isnull check on attribute collection

* skip query on attribute collection test

* exclude tags for jpa datastore

* ieEmpty returns false if there is null association

* Add exception if toMany association exist on the path and is not target collection

* Add IT test

* remove unused code

* Fixing Typo

Co-authored-by: Aaron Klish <[email protected]>
  • Loading branch information
Chandrasekar-Rajasekar and aklish authored Feb 15, 2020
1 parent ff88afb commit 236ed8f
Show file tree
Hide file tree
Showing 21 changed files with 603 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ public static boolean toManyInPath(EntityDictionary dictionary, Path path) {
.anyMatch(RelationshipType::isToMany);
}

public static boolean toManyInPathExceptLastPathElement(EntityDictionary dictionary, Path path) {
int pathLength = path.getPathElements().size();
return path.getPathElements().stream()
.limit(pathLength - 1)
.map(element -> dictionary.getRelationshipType(element.getType(), element.getFieldName()))
.anyMatch(RelationshipType::isToMany);
}

public FilterPredicate(PathElement pathElement, Operator op, List<Object> values) {
this(new Path(Collections.singletonList(pathElement)), op, values);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2019, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.core.filter;

import com.yahoo.elide.core.Path;

import java.util.Collections;

/**
* Is Empty Predicate Class.
*/
public class IsEmptyPredicate extends FilterPredicate {

public IsEmptyPredicate(Path path) {
super(path, Operator.ISEMPTY, Collections.emptyList());
}

public IsEmptyPredicate(Path.PathElement pathElement) {
super(pathElement, Operator.ISEMPTY, Collections.emptyList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2019, Yahoo Inc.
* Licensed under the Apache License, Version 2.0
* See LICENSE file in project root for terms.
*/
package com.yahoo.elide.core.filter;

import com.yahoo.elide.core.Path;

import java.util.Collections;

/**
* Not Empty Predicate Class.
*/
public class NotEmptyPredicate extends FilterPredicate {

public NotEmptyPredicate(Path path) {
super(path, Operator.NOTEMPTY, Collections.emptyList());
}

public NotEmptyPredicate(Path.PathElement pathElement) {
super(pathElement, Operator.NOTEMPTY, Collections.emptyList());
}
}
34 changes: 34 additions & 0 deletions elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.function.Predicate;

Expand Down Expand Up @@ -150,6 +152,20 @@ public <T> Predicate<T> contextualize(String field, List<Object> values, Request
return isFalse();
}
},

ISEMPTY("isempty", false) {
@Override
public <T> Predicate<T> contextualize(String field, List<Object> values, RequestScope requestScope) {
return isEmpty(field, requestScope);
}
},

NOTEMPTY("notempty", false) {
@Override
public <T> Predicate<T> contextualize(String field, List<Object> values, RequestScope requestScope) {
return (entity) -> !isEmpty(field, requestScope).test(entity);
}
}
;

public static final Function<String, String> FOLD_CASE = s -> s.toLowerCase(Locale.ENGLISH);
Expand All @@ -171,6 +187,8 @@ public <T> Predicate<T> contextualize(String field, List<Object> values, Request
FALSE.negated = TRUE;
ISNULL.negated = NOTNULL;
NOTNULL.negated = ISNULL;
ISEMPTY.negated = NOTEMPTY;
NOTEMPTY.negated = ISEMPTY;
}

/**
Expand Down Expand Up @@ -316,6 +334,22 @@ private static <T> Predicate<T> isFalse() {
return (T entity) -> false;
}

private static <T> Predicate<T> isEmpty(String field, RequestScope requestScope) {
return (T entity) -> {

Object val = getFieldValue(entity, field, requestScope);
if (val == null) { return false; }
if (val instanceof Collection<?>) {
return ((Collection<?>) val).isEmpty();
}
if (val instanceof Map<?, ?>) {
return ((Map<?, ?>) val).isEmpty();
}

return false;
};
}

/**
* Return value of field/path for given entity. For example this.book.author
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
*/
public class DefaultFilterDialect implements JoinFilterDialect, SubqueryFilterDialect {
private final EntityDictionary dictionary;

public DefaultFilterDialect(EntityDictionary dictionary) {
this.dictionary = dictionary;
}
Expand Down Expand Up @@ -128,10 +127,7 @@ public Map<String, FilterExpression> parseTypedExpression(String path, Multivalu
List<FilterPredicate> filterPredicates = extractPredicates(filterParams);

for (FilterPredicate filterPredicate : filterPredicates) {
if (FilterPredicate.toManyInPath(dictionary, filterPredicate.getPath())) {
throw new ParseException("Invalid toMany join: " + filterPredicate);
}

validateFilterPredicate(filterPredicate);
String entityType = dictionary.getJsonAliasFor(filterPredicate.getEntityType());
FilterExpression filterExpression = expressionMap.get(entityType);
if (filterExpression != null) {
Expand Down Expand Up @@ -192,4 +188,37 @@ private Path getPath(final String[] keyParts) throws ParseException {

return new Path(path);
}

/**
* Check if the relation type in filter predicate is allowed for an operator.
* Defaults behavior is to prevent filter on toMany relationship.
* @param filterPredicate
* @throws ParseException
*/
private void validateFilterPredicate(FilterPredicate filterPredicate) throws ParseException {
switch (filterPredicate.getOperator()) {
case ISEMPTY:
case NOTEMPTY:
emptyOperatorConditions(filterPredicate);
break;
default:
if (FilterPredicate.toManyInPath(dictionary, filterPredicate.getPath())) {
throw new ParseException("Invalid toMany join: " + filterPredicate);
}
}
}

/**
* Check if the predicate has toMany relationship that is not target relationship
* on which the empty check is performed.
* @param filterPredicate
* @throws ParseException
*/
private void emptyOperatorConditions(FilterPredicate filterPredicate) throws ParseException {
if (FilterPredicate.toManyInPathExceptLastPathElement(dictionary, filterPredicate.getPath())) {
throw new ParseException(
"Invalid toMany join. toMany association has to be the target collection."
+ filterPredicate);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import com.yahoo.elide.core.exceptions.InvalidValueException;
import com.yahoo.elide.core.filter.FilterPredicate;
import com.yahoo.elide.core.filter.InPredicate;
import com.yahoo.elide.core.filter.IsEmptyPredicate;
import com.yahoo.elide.core.filter.IsNullPredicate;
import com.yahoo.elide.core.filter.NotEmptyPredicate;
import com.yahoo.elide.core.filter.NotNullPredicate;
import com.yahoo.elide.core.filter.Operator;
import com.yahoo.elide.core.filter.expression.AndFilterExpression;
Expand Down Expand Up @@ -57,6 +59,7 @@ public class RSQLFilterDialect implements SubqueryFilterDialect, JoinFilterDiale
private static final String INVALID_QUERY_PARAMETER = "Invalid query parameter: ";
private static final Pattern TYPED_FILTER_PATTERN = Pattern.compile("filter\\[([^\\]]+)\\]");
private static final ComparisonOperator ISNULL_OP = new ComparisonOperator("=isnull=", false);
private static final ComparisonOperator ISEMPTY_OP = new ComparisonOperator("=isempty=", false);

/* Subset of operators that map directly to Elide operators */
private static final Map<ComparisonOperator, Operator> OPERATOR_MAP =
Expand Down Expand Up @@ -86,6 +89,7 @@ public RSQLFilterDialect(EntityDictionary dictionary, CaseSensitivityStrategy ca
private static Set<ComparisonOperator> getDefaultOperatorsWithIsnull() {
Set<ComparisonOperator> operators = RSQLOperators.defaultOperators();
operators.add(ISNULL_OP);
operators.add(ISEMPTY_OP);
return operators;
}

Expand Down Expand Up @@ -286,6 +290,18 @@ public FilterExpression visit(ComparisonNode node, Class entityType) {
List<String> arguments = node.getArguments();
Path path = buildPath(entityType, relationship);

//handles '=isempty=' op before coerce arguments
// ToMany Association is allowed if the operation in Is Empty
if (op.equals(ISEMPTY_OP)) {
if (FilterPredicate.toManyInPathExceptLastPathElement(dictionary, path)
&& !allowNestedToManyAssociations) {
throw new RSQLParseException(
String.format("Invalid association %s. toMany association has to be the target collection.",
relationship));
}
return buildIsEmptyOperator(path, arguments);
}

if (FilterPredicate.toManyInPath(dictionary, path) && !allowNestedToManyAssociations) {
throw new RSQLParseException(String.format("Invalid association %s", relationship));
}
Expand Down Expand Up @@ -370,5 +386,25 @@ private FilterExpression buildIsNullOperator(Path path, List<String> arguments)
throw new RSQLParseException(String.format("Invalid value for operator =isnull= '%s'", arg));
}
}

/**
* Returns Predicate for '=isempty=' case depending on its arguments.
* <p>
* NOTE: Filter Expression builder specially for '=isempty=' case.
*
* @return
*/
private FilterExpression buildIsEmptyOperator(Path path, List<String> arguments) {
String arg = arguments.get(0);
try {
boolean wantsEmpty = CoerceUtil.coerce(arg, boolean.class);
if (wantsEmpty) {
return new IsEmptyPredicate(path);
}
return new NotEmptyPredicate(path);
} catch (InvalidValueException ignored) {
throw new RSQLParseException(String.format("Invalid value for operator =isempty= '%s'", arg));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import com.yahoo.elide.core.exceptions.InvalidValueException;
import com.yahoo.elide.security.checks.Check;
import example.Author;
import example.Book;

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

Expand Down Expand Up @@ -116,6 +118,37 @@ public void isnullAndNotnullTest() throws Exception {
assertFalse(fn.test(author));
}

@Test
public void isemptyAndNotemptyTest() throws Exception {
author = new Author();
author.setId(1L);
author.setAwards(Arrays.asList("Booker Prize", "National Book Awards"));
author.getBooks().add(new Book());

fn = Operator.ISEMPTY.contextualize("awards", null, requestScope);
assertFalse(fn.test(author));
fn = Operator.ISEMPTY.contextualize("books", null, requestScope);
assertFalse(fn.test(author));
fn = Operator.NOTEMPTY.contextualize("awards", null, requestScope);
assertTrue(fn.test(author));
fn = Operator.NOTEMPTY.contextualize("books", null, requestScope);
assertTrue(fn.test(author));


//name is null and books are null
author.setBooks(null);
author.setAwards(Arrays.asList());
fn = Operator.ISEMPTY.contextualize("awards", null, requestScope);
assertTrue(fn.test(author));
fn = Operator.ISEMPTY.contextualize("books", null, requestScope);
assertFalse(fn.test(author));
fn = Operator.NOTEMPTY.contextualize("awards", null, requestScope);
assertFalse(fn.test(author));
fn = Operator.NOTEMPTY.contextualize("books", null, requestScope);
assertTrue(fn.test(author));

}

@Test
public void prefixAndPostfixAndInfixTest() throws Exception {
author = new Author();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,17 @@ public void testInvalidTypeQualifier() throws ParseException {

assertThrows(ParseException.class, () -> dialect.parseTypedExpression("/author", queryParams));
}

@Test
public void testEmptyOperatorException() throws Exception {
MultivaluedMap<String, String> queryParams = new MultivaluedHashMap<>();

queryParams.add(
"filter[book.authors.name][isempty]",
""
);

assertThrows(ParseException.class,
() -> dialect.parseTypedExpression("/book", queryParams));
}
}
Loading

0 comments on commit 236ed8f

Please sign in to comment.