From 0c0dc21f8eb65fba3e4500f8d9118bc6fcf0ea51 Mon Sep 17 00:00:00 2001 From: Michael Reiche <48999328+mikereiche@users.noreply.github.com> Date: Tue, 7 Apr 2020 07:45:53 -0700 Subject: [PATCH] DATACOUCH-515 implement additional operators in N1qlQueryCreator and QueryCriteria (#213) Co-authored-by: mikereiche --- .../couchbase/core/query/QueryCriteria.java | 237 ++++++++++++++++-- .../repository/query/N1qlQueryCreator.java | 49 +++- .../core/query/QueryCriteriaTests.java | 157 ++++++++++++ 3 files changed, 420 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java b/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java index 9b30e70d..9e1637d7 100644 --- a/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java +++ b/src/main/java/org/springframework/data/couchbase/core/query/QueryCriteria.java @@ -3,6 +3,8 @@ package org.springframework.data.couchbase.core.query; import org.springframework.lang.Nullable; import java.util.ArrayList; +import java.util.Formatter; +import java.util.LinkedList; import java.util.List; public class QueryCriteria implements QueryCriteriaDefinition { @@ -10,7 +12,7 @@ public class QueryCriteria implements QueryCriteriaDefinition { /** * Holds the chain itself, the current operator being always the last one. */ - private final List criteriaChain; + private List criteriaChain; /** * Represents how the chain is hung together, null only for the first element. @@ -19,14 +21,25 @@ public class QueryCriteria implements QueryCriteriaDefinition { private final String key; private String operator; - private Object value; + private Object[] value; + private String format; + + QueryCriteria(List chain, String key, Object[] value, ChainOperator chainOperator) { + this(chain, key, value, chainOperator, null, null); + } QueryCriteria(List chain, String key, Object value, ChainOperator chainOperator) { + this(chain, key, new Object[]{value}, chainOperator, null, null); + } + + QueryCriteria(List chain, String key, Object[] value, ChainOperator chainOperator, String operator, String format) { this.criteriaChain = chain; criteriaChain.add(this); this.key = key; this.value = value; this.chainOperator = chainOperator; + this.operator = operator; + this.format = format; } /** @@ -52,60 +65,220 @@ public class QueryCriteria implements QueryCriteriaDefinition { return new QueryCriteria(this.criteriaChain, key, null, ChainOperator.OR); } + private static QueryCriteria wrap(QueryCriteria criteria) { + QueryCriteria qc = new QueryCriteria( + new LinkedList(), + criteria.key, + criteria.value, + null, + criteria.operator, + criteria.format); + return qc; + } + public QueryCriteria eq(@Nullable Object o) { return is(o); } public QueryCriteria is(@Nullable Object o) { operator = "="; - value = o; + value = new Object[]{o}; return this; } public QueryCriteria ne(@Nullable Object o) { operator = "!="; - value = o; + value = new Object[]{o}; return this; } public QueryCriteria lt(@Nullable Object o) { operator = "<"; - value = o; + value = new Object[]{o}; return this; } public QueryCriteria lte(@Nullable Object o) { operator = "<="; - value = o; + value = new Object[]{o}; return this; } public QueryCriteria gt(@Nullable Object o) { operator = ">"; - value = o; + value = new Object[]{o}; return this; } public QueryCriteria gte(@Nullable Object o) { operator = ">="; - value = o; + value = new Object[]{o}; return this; } + public QueryCriteria startingWith(@Nullable Object o) { + if (o instanceof QueryCriteria) { + QueryCriteria term = where("\"%\"").plus(o); + like(term); + } else { + like(o.toString() + "%"); + } + return this; + } + + public QueryCriteria plus(@Nullable Object o) { + operator = "PLUS"; + value = new Object[]{o}; + format = "(%1$s + %3$s)"; + return this; + } + + public QueryCriteria endingWith(@Nullable Object o) { + if (o instanceof QueryCriteria) { + QueryCriteria term = where("%").plus(o); + like(term); + } else { + like("%" + o.toString()); + } + return this; + } + + public QueryCriteria regex(@Nullable Object o) { + operator = "REGEXP_LIKE"; + value = new Object[]{o}; + format = "regexp_like(%1$s, %3$s)"; + return this; + } + + public QueryCriteria containing(@Nullable Object o) { + operator = "CONTAINS"; + value = new Object[]{o}; + format = "contains(%1$s, %3$s)"; + return this; + } + + public QueryCriteria notContaining(@Nullable Object o) { + value = new QueryCriteria[]{wrap(containing(o))}; + operator = "NOT"; + format = format = "not( %3$s )"; + return this; + } + + public QueryCriteria like(@Nullable Object o) { + operator = "LIKE"; + value = new Object[]{o}; + format = "%1$s like %3$s"; + return this; + } + + public QueryCriteria notLike(@Nullable Object o) { + value = new QueryCriteria[]{wrap(like(o))}; + operator = "NOT"; + format = format = "not( %3$s )"; + return (QueryCriteria) this; + } + + public QueryCriteria isNull() { + operator = "IS_NULL"; + value = null; + format = "%1$s is null"; + return this; + } + + public QueryCriteria isNotNull() { + operator = "IS_NOT_NULL"; + value = null; + format = "%1$s is not null"; + return (QueryCriteria) this; + } + + public QueryCriteria isMissing() { + operator = "IS_MISSING"; + value = null; + format = "%1$s is missing"; + return this; + } + + public QueryCriteria isNotMissing() { + operator = "IS_NOT_MiSSING"; + value = null; + format = "%1$s is not missing"; + return (QueryCriteria) this; + } + + public QueryCriteria isValued() { + operator = "IS_VALUED"; + value = null; + format = "%1$s is valued"; + return this; + } + + public QueryCriteria isNotValued() { + operator = "IS_NOT_VALUED"; + value = null; + format = "%1$s is not valued"; + return (QueryCriteria) this; + } + + public QueryCriteria within(@Nullable Object o) { + operator = "WITHIN"; + value = new Object[]{o}; + format = "%1$s within $3$s"; + return (QueryCriteria) this; + } + + public QueryCriteria between(@Nullable Object o1, @Nullable Object o2) { + operator = "BETWEEN"; + value = new Object[]{o1, o2}; + format = "%1$s between %3$s and %4$s"; + return (QueryCriteria) this; + } + + public QueryCriteria in(@Nullable Object... o) { + operator = "IN"; + value = o; + StringBuilder sb = new StringBuilder("%1$s in ( [ "); + for (int i = 1; i <= value.length; i++) { // format indices start at 1 + if (i > 1) sb.append(", "); + sb.append("%" + (i + 2) + "$s"); // the first is fieldName, second is operator, args start at 3 + } + format = sb.append(" ] )").toString(); + return (QueryCriteria) this; + } + + public QueryCriteria notIn(@Nullable Object... o) { + value = new QueryCriteria[]{wrap(in(o))}; + operator = "NOT"; + format = format = "not( %3$s )"; // field = 1$, operator = 2$, value=$3, $4, ... + return (QueryCriteria) this; + } + + public QueryCriteria TRUE() { // true/false are reserved, use TRUE/FALSE + value = null; + operator = null; + format = format = "%1$s"; // field = 1$, operator = 2$, value=$3, $4, ... + return (QueryCriteria) this; + } + + public QueryCriteria FALSE() { + value = new QueryCriteria[]{wrap(TRUE())}; + operator = "not"; + format = format = "not( %3$s )"; + return (QueryCriteria) this; + } + @Override public String export() { StringBuilder output = new StringBuilder(); - boolean first = true; for (QueryCriteria c : this.criteriaChain) { if (!first) { if (c.chainOperator == null) { throw new IllegalStateException("A chain operator must be present when chaining!"); } - + // the consistent place to output this would be in the c.toQueryString(output) about five lines down output.append(" ").append(c.chainOperator.representation).append(" "); - } - if (first) { + } else { first = false; } c.exportSingle(output); @@ -114,15 +287,32 @@ public class QueryCriteria implements QueryCriteriaDefinition { return output.toString(); } - protected void exportSingle(final StringBuilder sb) { - if (value instanceof QueryCriteria) { - sb.append("(").append(((QueryCriteria) value).export()).append(")"); - } else { - String fieldName = "`" + key + "`"; - sb.append(fieldName).append(" ").append(operator).append(" ").append(maybeWrapValue(value)); + private StringBuilder exportSingle(StringBuilder sb) { + String fieldName = maybeQuote(key); + int valueLen = value == null ? 0 : value.length; + Object[] v = new Object[valueLen + 2]; + v[0] = fieldName; + v[1] = operator; + for (int i = 0; i < valueLen; i++) { + if (value[i] instanceof QueryCriteria) { + v[i + 2] = "(" + ((QueryCriteria) value[i]).export() + ")"; + } else { + v[i + 2] = maybeWrapValue(value[i]); + } } + + if (key == null) { // chaining, the chainingOperator was already output by export() + sb.append(v[2]); + } else if (format == null) { // this always has to be fieldname + sb.append(fieldName).append(" ").append(operator).append(" ").append(v[2]); + } else { + sb.append(new Formatter().format(format, v)); + } + + return sb; } + private String maybeWrapValue(Object value) { if (value instanceof String) { return "\"" + value + "\""; @@ -133,10 +323,17 @@ public class QueryCriteria implements QueryCriteriaDefinition { } } + private String maybeQuote(String value) { + if (value == null || (value.startsWith("\"") && value.endsWith("\""))) { + return value; + } else { + return "`" + value + "`"; + } + } + enum ChainOperator { AND("and"), - OR("or"), - NOT("not"); + OR("or"); private final String representation; diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java b/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java index 53cfca1f..f9673258 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java @@ -21,7 +21,7 @@ public class N1qlQueryCreator extends AbstractQueryCreator private final MappingContext context; public N1qlQueryCreator(final PartTree tree, final ParameterAccessor accessor, - final MappingContext context) { + final MappingContext context) { super(tree, accessor); this.accessor = accessor; this.context = context; @@ -57,21 +57,64 @@ public class N1qlQueryCreator extends AbstractQueryCreator } private QueryCriteria from(final Part part, final CouchbasePersistentProperty property, final QueryCriteria criteria, - final Iterator parameters) { + final Iterator parameters) { final Part.Type type = part.getType(); - +/* + NEAR(new String[]{"IsNear", "Near"}), + WITHIN(new String[]{"IsWithin", "Within"}), + */ switch (type) { case GREATER_THAN: + case AFTER: return criteria.gt(parameters.next()); case GREATER_THAN_EQUAL: return criteria.gte(parameters.next()); case LESS_THAN: + case BEFORE: return criteria.lt(parameters.next()); case LESS_THAN_EQUAL: return criteria.lte(parameters.next()); case SIMPLE_PROPERTY: return criteria.eq(parameters.next()); + case NEGATING_SIMPLE_PROPERTY: + return criteria.ne(parameters.next()); + case CONTAINING: + return criteria.containing(parameters.next()); + case NOT_CONTAINING: + return criteria.notContaining(parameters.next()); + case STARTING_WITH: + return criteria.startingWith(parameters.next()); + case ENDING_WITH: + return criteria.endingWith(parameters.next()); + case LIKE: + return criteria.like(parameters.next()); + case NOT_LIKE: + return criteria.notLike(parameters.next()); + case WITHIN: + return criteria.within(parameters.next()); + case IS_NULL: + return criteria.isNull(/*parameters.next()*/); + case IS_NOT_NULL: + return criteria.isNotNull(/*parameters.next()*/); + case IS_EMPTY: + return criteria.isNotValued(/*parameters.next()*/); + case IS_NOT_EMPTY: + return criteria.isValued(/*parameters.next()*/); + case EXISTS: + return criteria.isNotMissing(/*parameters.next()*/); + case REGEX: + return criteria.regex(parameters.next()); + case BETWEEN: + return criteria.between(parameters.next(), parameters.next()); + case IN: + return criteria.in((Object[]) parameters.next()); + case NOT_IN: + return criteria.notIn((Object[]) parameters.next()); + case TRUE: + return criteria.TRUE(); + case FALSE: + return criteria.FALSE(); default: throw new IllegalArgumentException("Unsupported keyword!"); } diff --git a/src/test/java/org/springframework/data/couchbase/core/query/QueryCriteriaTests.java b/src/test/java/org/springframework/data/couchbase/core/query/QueryCriteriaTests.java index 6022d13d..37b152ac 100644 --- a/src/test/java/org/springframework/data/couchbase/core/query/QueryCriteriaTests.java +++ b/src/test/java/org/springframework/data/couchbase/core/query/QueryCriteriaTests.java @@ -65,4 +65,161 @@ class QueryCriteriaTests { assertEquals("`name` = \"Bubba\" or (`age` > 12 or `country` = \"Austria\")", c.export()); } + + @Test + void testNestedNotIn() { + QueryCriteria c = + where("name").is("Bubba").or( + where("age").gt(12).or("country").is("Austria")).and( + where("state").notIn(new String[]{"Alabama", "Florida"})); + assertEquals("`name` = \"Bubba\" or (`age` > 12 or `country` = \"Austria\") and " + + "(not( (`state` in ( [ \"Alabama\", \"Florida\" ] )) ))", + c.export()); + } + + + @Test + void testLt() { + QueryCriteria c = where("name").lt("Couch"); + assertEquals("`name` < \"Couch\"", c.export()); + } + + @Test + void testLte() { + QueryCriteria c = where("name").lte("Couch"); + assertEquals("`name` <= \"Couch\"", c.export()); + } + + @Test + void testGt() { + QueryCriteria c = where("name").gt("Couch"); + assertEquals("`name` > \"Couch\"", c.export()); + } + + @Test + void testGte() { + QueryCriteria c = where("name").gte("Couch"); + assertEquals("`name` >= \"Couch\"", c.export()); + } + + @Test + void testNe() { + QueryCriteria c = where("name").ne("Couch"); + assertEquals("`name` != \"Couch\"", c.export()); + } + + @Test + void testStartingWith() { + QueryCriteria c = where("name").startingWith("Cou"); + assertEquals("`name` like \"Cou%\"", c.export()); + } + + @Test + void testStartingWithExpr() { + QueryCriteria c = where("name").startingWith(where("name").plus("")); + assertEquals("`name` like ((\"%\" + ((`name` + \"\"))))", c.export()); + } + + @Test + void testEndingWith() { + QueryCriteria c = where("name").endingWith("ouch"); + assertEquals("`name` like \"%ouch\"", c.export()); + } + + @Test + void testRegex() { + QueryCriteria c = where("name").regex("C.*h"); + assertEquals("regexp_like(`name`, \"C.*h\")", c.export()); + } + + @Test + void testContaining() { + QueryCriteria c = where("name").containing("ouch"); + assertEquals("contains(`name`, \"ouch\")", c.export()); + } + + @Test + void testNotContaining() { + QueryCriteria c = where("name").notContaining("Elvis"); + assertEquals("not( (contains(`name`, \"Elvis\")) )", c.export()); + } + + @Test + void testLike() { + QueryCriteria c = where("name").like("%ouch%"); + assertEquals("`name` like \"%ouch%\"", c.export()); + } + + @Test + void testNotLike() { + QueryCriteria c = where("name").notLike("%Elvis%"); + assertEquals("not( (`name` like \"%Elvis%\") )", c.export()); + } + + + @Test + void testIsNull() { + QueryCriteria c = where("name").isNull(); + assertEquals("`name` is null", c.export()); + } + + @Test + void testIsNotNull() { + QueryCriteria c = where("name").isNotNull(); + assertEquals("`name` is not null", c.export()); + } + + @Test + void testIsMissing() { + QueryCriteria c = where("name").isMissing(); + assertEquals("`name` is missing", c.export()); + } + + @Test + void testIsNotMissing() { + QueryCriteria c = where("name").isNotMissing(); + assertEquals("`name` is not missing", c.export()); + } + + @Test + void testIsValued() { + QueryCriteria c = where("name").isValued(); + assertEquals("`name` is valued", c.export()); + } + + @Test + void testIsNotValued() { + QueryCriteria c = where("name").isNotValued(); + assertEquals("`name` is not valued", c.export()); + } + + @Test + void testBetween() { + QueryCriteria c = where("name").between("Davis", "Gump"); + assertEquals("`name` between \"Davis\" and \"Gump\"", c.export()); + } + + @Test + void testIn() { + QueryCriteria c = where("name").in(new String[]{"gump", "davis"}); + assertEquals("`name` in ( [ \"gump\", \"davis\" ] )", c.export()); + } + + @Test + void testNotIn() { + QueryCriteria c = where("name").notIn(new String[]{"gump", "davis"}); + assertEquals("not( (`name` in ( [ \"gump\", \"davis\" ] )) )", c.export()); + } + + @Test + void testTrue() { + QueryCriteria c = where("name").TRUE(); + assertEquals("`name`", c.export()); + } + + @Test + void testFalse() { + QueryCriteria c = where("name").FALSE(); + assertEquals("not( (`name`) )", c.export()); + } } \ No newline at end of file