From a15da5edd710d4c06cf4517261ba0ff7335ab05c Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 14:54:38 +0100 Subject: [PATCH 01/14] Support IN operation for lists --- .../antlr4/com/aerospike/dsl/Condition.g4 | 5 +- .../dsl/parts/ExpressionContainer.java | 1 + .../visitor/ExpressionConditionVisitor.java | 56 +++ .../aerospike/dsl/visitor/VisitorUtils.java | 28 ++ .../dsl/expression/InExpressionsTests.java | 414 ++++++++++++++++++ .../dsl/parsedExpression/InFilterTests.java | 361 +++++++++++++++ 6 files changed, 864 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java create mode 100644 src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java diff --git a/src/main/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index 16f649b..f27bbba 100644 --- a/src/main/antlr4/com/aerospike/dsl/Condition.g4 +++ b/src/main/antlr4/com/aerospike/dsl/Condition.g4 @@ -33,6 +33,7 @@ comparisonExpression | bitwiseExpression '<=' bitwiseExpression # LessThanOrEqualExpression | bitwiseExpression '==' bitwiseExpression # EqualityExpression | bitwiseExpression '!=' bitwiseExpression # InequalityExpression + | bitwiseExpression IN bitwiseExpression # InExpression | bitwiseExpression # BitwiseExpressionWrapper ; @@ -129,7 +130,7 @@ stringOperand: QUOTED_STRING; QUOTED_STRING: ('\'' (~'\'')* '\'') | ('"' (~'"')* '"'); -listConstant: '[' unaryExpression? (',' unaryExpression)* ']'; +listConstant: '[' unaryExpression? (',' unaryExpression)* ']' | LIST_TYPE_DESIGNATOR; orderedMapConstant: '{' mapPairConstant? (',' mapPairConstant)* '}'; @@ -532,6 +533,8 @@ pathFunctionParams: pathFunctionParam (',' pathFunctionParam)*?; pathFunctionParam: pathFunctionParamName ':' pathFunctionParamValue; +IN: [iI][nN]; + NAME_IDENTIFIER: [a-zA-Z0-9_]+; WS: [ \t\r\n]+ -> skip; diff --git a/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java b/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java index ede641f..6f0d817 100644 --- a/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java +++ b/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java @@ -78,6 +78,7 @@ public enum ExprPartsOperation { GTEQ, LT, LTEQ, + IN, WITH_STRUCTURE, // unary WHEN_STRUCTURE, // unary EXCLUSIVE_STRUCTURE, // unary diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index a12209d..e509b7d 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -259,6 +259,62 @@ private static long negateLongLiteral(long value, String input) { return -value; } + @Override + public AbstractPart visitInExpression(ConditionParser.InExpressionContext ctx) { + AbstractPart left = visit(ctx.bitwiseExpression(0)); + AbstractPart right = visit(ctx.bitwiseExpression(1)); + + validateInRightOperand(left, right); + inferInTypes(left, right); + + return new ExpressionContainer(left, right, ExpressionContainer.ExprPartsOperation.IN); + } + + private static void validateInRightOperand(AbstractPart left, AbstractPart right) { + if (left == null) { + throw new DslParseException("Unable to parse left operand"); + } + if (right == null) { + throw new DslParseException("Unable to parse right operand"); + } + if (right.getPartType() == AbstractPart.PartType.PLACEHOLDER_OPERAND + || right.getPartType() == AbstractPart.PartType.LIST_OPERAND + || right.getPartType() == AbstractPart.PartType.BIN_PART + || right.getPartType() == AbstractPart.PartType.PATH_OPERAND + || right.getPartType() == AbstractPart.PartType.VARIABLE_OPERAND) { + return; + } + throw new DslParseException("IN operation requires a List as the right operand"); + } + + private static void inferInTypes(AbstractPart left, AbstractPart right) { + if (right.getPartType() == AbstractPart.PartType.BIN_PART) { + ((BinPart) right).updateExp(Exp.Type.LIST); + } + if (left.getPartType() == AbstractPart.PartType.BIN_PART + && !((BinPart) left).isTypeExplicitlySet() + && right.getPartType() == AbstractPart.PartType.LIST_OPERAND) { + ListOperand listOperand = (ListOperand) right; + Exp.Type inferredType = inferTypeFromListElements(listOperand); + if (inferredType != null) { + ((BinPart) left).updateExp(inferredType); + } + } + } + + private static Exp.Type inferTypeFromListElements(ListOperand listOperand) { + List values = listOperand.getValue(); + if (values.isEmpty()) return null; + Object first = values.get(0); + if (first instanceof String) return Exp.Type.STRING; + if (first instanceof Boolean) return Exp.Type.BOOL; + if (first instanceof Float || first instanceof Double) return Exp.Type.FLOAT; + if (first instanceof Integer || first instanceof Long) return Exp.Type.INT; + if (first instanceof java.util.List) return Exp.Type.LIST; + if (first instanceof java.util.Map) return Exp.Type.MAP; + return null; + } + @Override public AbstractPart visitGreaterThanExpression(ConditionParser.GreaterThanExpressionContext ctx) { AbstractPart left = visit(ctx.bitwiseExpression(0)); diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index b67e92e..5c25d7b 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -5,7 +5,9 @@ import com.aerospike.dsl.Index; import com.aerospike.dsl.PlaceholderValues; import com.aerospike.dsl.client.cdt.CTX; +import com.aerospike.dsl.client.cdt.ListReturnType; import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; import com.aerospike.dsl.client.query.Filter; import com.aerospike.dsl.client.query.IndexType; import com.aerospike.dsl.parts.AbstractPart; @@ -1136,6 +1138,10 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh boolean rightIsPlaceholder = !expr.isUnary() && expr.getRight().getPartType() == PLACEHOLDER_OPERAND; boolean isResolved = false; + if (expr.getOperationType() == IN && rightIsPlaceholder) { + validateInPlaceholderValue((PlaceholderOperand) expr.getRight(), placeholderValues); + } + // Resolve left placeholder and replace it with the resolved operand if (leftIsPlaceholder) { PlaceholderOperand placeholder = (PlaceholderOperand) expr.getLeft(); @@ -1153,6 +1159,14 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh } } + private static void validateInPlaceholderValue(PlaceholderOperand placeholder, + PlaceholderValues placeholderValues) { + Object value = placeholderValues.getValue(placeholder.getIndex()); + if (!(value instanceof java.util.List)) { + throw new DslParseException("IN operation requires a List as the right operand"); + } + } + /** * Builds an array of {@link CTX} for a given path. * This is the main entry point for building context based on the parsed expression tree. @@ -1333,6 +1347,11 @@ private static Exp processExpression(ExpressionContainer expr) { // For binary expressions AbstractPart right = getExistingPart(expr.getRight(), "Unable to parse right operand"); + // IN operation: ListExp.getByValue(EXISTS, leftExp, rightExp) + if (expr.getOperationType() == IN) { + return buildInExpression(left, right); + } + // Process operands Exp leftExp = processOperand(left); Exp rightExp = processOperand(right); @@ -1444,6 +1463,12 @@ && resolveExpType(container.getLeft()) == Exp.Type.FLOAT) { return null; } + private static Exp buildInExpression(AbstractPart left, AbstractPart right) { + Exp leftExp = processOperand(left); + Exp rightExp = processOperand(right); + return ListExp.getByValue(ListReturnType.EXISTS, leftExp, rightExp); + } + private static boolean isArithmeticExpressionContainer(AbstractPart part) { return part instanceof ExpressionContainer container && container.getOperationType() != null @@ -1605,6 +1630,9 @@ private static Map> getExpressionsPerCardinal Consumer exprsPerCardinalityCollector = part -> { if (part.getPartType() == EXPRESSION_CONTAINER) { ExpressionContainer expr = (ExpressionContainer) part; + + if (expr.getOperationType() == IN) return; + BinPart binPart = getBinPart(expr, 2); if (binPart == null) return; // no bin found diff --git a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java new file mode 100644 index 0000000..f4eabce --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java @@ -0,0 +1,414 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.DslParseException; +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.PlaceholderValues; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExp; +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InExpressionsTests { + + @Test + void stringLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare(ExpressionContext.of("\"gold\" in [\"gold\", \"silver\"]"), expected); + } + + @Test + void intLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.val(List.of(100, 200, 300))); + parseFilterExpressionAndCompare(ExpressionContext.of("100 in [100, 200, 300]"), expected); + } + + @Test + void floatLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); + parseFilterExpressionAndCompare(ExpressionContext.of("1.5 in [1.0, 2.0, 3.0]"), expected); + } + + @Test + void boolLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare(ExpressionContext.of("true in [true, false]"), expected); + } + + @Test + void listLiteralInListOfLists() { + List> outerList = List.of( + List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("[1,2,3] in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); + } + + @Test + void listBinInListOfLists() { + List> outerList = List.of( + List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("listBin"), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.listBin in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); + } + + @Test + void mapLiteralInListOfMaps() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.val(List.of(map, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("{1: \"a\"} in [{1: \"a\"}, {2: \"b\"}]"), expected); + } + + @Test + void mapBinInListOfMaps() { + TreeMap map1 = new TreeMap<>(); + map1.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("mapBin"), Exp.val(List.of(map1, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.mapBin in [{1: \"a\"}, {2: \"b\"}]"), expected); + } + + @Test + void binInStringListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name in [\"Bob\", \"Mary\"]"), expected); + } + + @Test + void binInIntListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(18, 21, 65))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age in [18, 21, 65]"), expected); + } + + @Test + void binInFloatListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("score"), Exp.val(List.of(1.0, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.score in [1.0, 2.5]"), expected); + } + + @Test + void binInBoolListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("isActive"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.isActive in [true, false]"), expected); + } + + @Test + void stringLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.listBin("allowedStatuses")); + parseFilterExpressionAndCompare( + ExpressionContext.of("\"gold\" in $.allowedStatuses"), expected); + } + + @Test + void intLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.listBin("allowedValues")); + parseFilterExpressionAndCompare( + ExpressionContext.of("100 in $.allowedValues"), expected); + } + + @Test + void floatLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.listBin("scores")); + parseFilterExpressionAndCompare( + ExpressionContext.of("1.5 in $.scores"), expected); + } + + @Test + void boolLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.listBin("flags")); + parseFilterExpressionAndCompare( + ExpressionContext.of("true in $.flags"), expected); + } + + @Test + void listLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(List.of(1, 2, 3)), Exp.listBin("listOfLists")); + parseFilterExpressionAndCompare( + ExpressionContext.of("[1, 2, 3] in $.listOfLists"), expected); + } + + @Test + void mapLiteralInBin() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.listBin("mapItems")); + parseFilterExpressionAndCompare( + ExpressionContext.of("{1: \"a\"} in $.mapItems"), expected); + } + + @Test + void binInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("itemType"), Exp.listBin("allowedItems")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.itemType in $.allowedItems"), expected); + } + + @Test + void nestedPathInListLiteral() { + parseFilterExp(ExpressionContext.of( + "$.rooms.room1.rates.rateType in [\"RACK_RATE\", \"DISCOUNT\"]")); + } + + @Test + void caseInsensitiveIn() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name IN [\"Bob\"]"), expected); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name In [\"Bob\"]"), expected); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name iN [\"Bob\"]"), expected); + } + + @Test + void inWithAndOperator() { + Exp expected = Exp.and( + Exp.gt(Exp.intBin("cost"), Exp.val(50)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("status"), Exp.val(List.of("active", "pending")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.cost > 50 and $.status in [\"active\", \"pending\"]"), expected); + } + + @Test + void inWithOrOperator() { + Exp expected = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("status"), Exp.val(List.of("active"))), + Exp.gt(Exp.intBin("priority"), Exp.val(5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.status in [\"active\"] or $.priority > 5"), expected); + } + + @Test + void complexExpressionWithIn() { + Exp expected = Exp.and( + Exp.gt(Exp.intBin("cost"), Exp.val(50)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("status"), Exp.listBin("allowedStatuses")), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("available"), Exp.listBin("bookableStates"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.cost > 50 and $.status in $.allowedStatuses" + + " and \"available\" in $.bookableStates"), expected); + } + + @Test + void inWithParentheses() { + Exp expected = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.gt(Exp.intBin("age"), Exp.val(18))); + parseFilterExpressionAndCompare( + ExpressionContext.of("($.name in [\"Bob\"]) and $.age > 18"), expected); + } + + @Test + void inInsideWithStructure() { + Exp expected = Exp.let( + Exp.def("allowed", Exp.val(List.of("Bob", "Mary"))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.var("allowed"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(allowed = [\"Bob\", \"Mary\"])" + + " do ($.name.get(type: STRING) in ${allowed})"), expected); + } + + @Test + void inInsideWhenCondition() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.val("VIP"), + Exp.val("regular")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.name.get(type: STRING) in [\"Bob\"] => \"VIP\"," + + " default => \"regular\")"), expected); + } + + @Test + void placeholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [\"gold\", \"silver\"]", + PlaceholderValues.of("gold")), expected); + } + + @Test + void placeholderAsRightOperand() { + parseFilterExp(ExpressionContext.of("$.name in ?0")); + } + + @Test + void inWithEmptyList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("name"), Exp.val(Collections.emptyList())); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name in []"), expected); + } + + @Test + void inWithSingleElementList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name in [\"Bob\"]"), expected); + } + + @Test + void inWithNegativeInts() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(-1, 0, 1))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1, 0, 1]"), expected); + } + + @Test + void inWithNegativeFloats() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(-1.5, 0.0, 1.5))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1.5, 0.0, 1.5]"), expected); + } + + @Test + void inWithHexBinaryLiterals() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(255, 5, 42))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [0xFF, 0b101, 42]"), expected); + } + + @Test + void notWrappingIn() { + Exp expected = Exp.not(ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("not($.name in [\"Bob\", \"Mary\"])"), expected); + } + + @Test + void arithmeticExprAsLeftIn() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.add(Exp.intBin("a"), Exp.val(5)), + Exp.val(List.of(10, 20, 30))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.a + 5 in [10, 20, 30]"), expected); + } + + @Test + void negativeRightOperandString() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in \"Bob\""))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandInt() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 42"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandFloat() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 1.5"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandBool() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in true"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in {\"a\": 1}"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandMetadata() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in $.ttl()"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToStr() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of("Bob")))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToInt() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToFloat() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToBool() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(Map.of("a", 1))))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } +} diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java new file mode 100644 index 0000000..f8088c4 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java @@ -0,0 +1,361 @@ +package com.aerospike.dsl.parsedExpression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.Index; +import com.aerospike.dsl.IndexContext; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import com.aerospike.dsl.client.query.Filter; +import com.aerospike.dsl.client.query.IndexType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.aerospike.dsl.util.TestUtils.NAMESPACE; +import static com.aerospike.dsl.util.TestUtils.parseDslExpressionAndCompare; + +class InFilterTests { + + // --- Single IN + comparison with indexes — IN always excluded from Filter --- + + @Test + void inAndEq_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void eqAndIn_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.equal("intBin1", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 == 100 and $.intBin2 in [1, 2, 3]"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void eqAndInAndLt_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.range("intBin3", Long.MIN_VALUE, 49); + Exp exp = Exp.and( + Exp.eq(Exp.intBin("intBin1"), Exp.val(100)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(1, 2, 3)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 == 100 and $.intBin2 in [1, 2, 3] and $.intBin3 < 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void inAndGt_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(10, 20))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [10, 20] and $.intBin2 > 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void gtAndIn_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(10, 20))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 > 100 and $.intBin2 in [10, 20]"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + // --- Two IN parts with indexes — never produce Filter --- + + @Test + void twoIns_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4]"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void twoIns_noIndexes() { + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4]"), + filter, exp); + } + + @Test + void twoInsAndEq_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin3", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4] and $.intBin3 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void eqAndTwoIns_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.equal("intBin1", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin3"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 == 100 and $.intBin2 in [1, 2] and $.intBin3 in [3, 4]"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void twoInsAndLtAndGt_allIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("b1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("b2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("b3").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("b4").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.range("b2", Long.MIN_VALUE, 49); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b1"), Exp.val(List.of(1))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b3"), Exp.val(List.of(2))), + Exp.gt(Exp.intBin("b4"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.b1 in [1] and $.b2 < 50 and $.b3 in [2] and $.b4 > 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + // --- IN bin has highest cardinality — fallback to next best --- + + @Test + void inBinHighestCard_fallback() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void inBinHighestCard_fallbackGt() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 > 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void inBinHighestCard_3exprs() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(5).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.lt(Exp.intBin("intBin3"), Exp.val(50))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 < 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void inBinOnlyIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + // --- Two IN bins with highest cardinality — fallback --- + + @Test + void twoInBinsHighestCard() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("b1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("b2").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("b3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("b3", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b1"), Exp.val(List.of(1))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b2"), Exp.val(List.of(2)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.b1 in [1] and $.b2 in [2] and $.b3 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void twoInBinsHighestCard_noOther() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("b1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("b2").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b1"), Exp.val(List.of(1))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b2"), Exp.val(List.of(2)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.b1 in [1] and $.b2 in [2]"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void twoInBinsHighCard_withGtLt() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("b1").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("b2").indexType(IndexType.NUMERIC) + .binValuesRatio(5).build(), + Index.builder().namespace(NAMESPACE).bin("b3").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("b4").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.range("b2", 51, Long.MAX_VALUE); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b1"), Exp.val(List.of(1))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b3"), Exp.val(List.of(2))), + Exp.lt(Exp.intBin("b4"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.b1 in [1] and $.b2 > 50 and $.b3 in [2] and $.b4 < 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } + + @Test + void twoInsOnlyIndexed() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("b1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("b2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b1"), Exp.val(List.of(1))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("b2"), Exp.val(List.of(2))), + Exp.eq(Exp.intBin("b3"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.b1 in [1] and $.b2 in [2] and $.b3 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes)); + } +} From 0f9cd31f88ba9d552b3516860a3628f3fbfe1fbe Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 19:37:05 +0100 Subject: [PATCH 02/14] Address lists, placeholders, type inference. --- .../dsl/parts/operand/OperandFactory.java | 22 ++- .../visitor/ExpressionConditionVisitor.java | 17 ++- .../aerospike/dsl/visitor/VisitorUtils.java | 3 + .../dsl/expression/InExpressionsTests.java | 142 +++++++++++++++++- 4 files changed, 175 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java index 8440693..638d511 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -2,17 +2,23 @@ import com.aerospike.dsl.parts.AbstractPart; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + /** * A factory for creating different types of {@link AbstractPart} operands based on a given value. *

* This factory provides a static method to dynamically create concrete operand implementations - * such as {@link StringOperand}, {@link BooleanOperand}, {@link FloatOperand}, and {@link IntOperand} - * from various Java primitive and wrapper types. It centralizes the logic for type-specific object creation. + * from various Java types. It centralizes the logic for type-specific object creation. * * @see StringOperand * @see BooleanOperand * @see FloatOperand * @see IntOperand + * @see ListOperand + * @see MapOperand */ public interface OperandFactory { @@ -25,6 +31,8 @@ public interface OperandFactory { *

  • {@link Boolean} to {@link BooleanOperand}.
  • *
  • {@link Float} or {@link Double} to {@link FloatOperand}.
  • *
  • {@link Integer} or {@link Long} to {@link IntOperand}.
  • + *
  • {@link List} to {@link ListOperand}.
  • + *
  • {@link Map} to {@link MapOperand}.
  • * * * @param value The object to be converted into an operand. This cannot be {@code null}. @@ -32,6 +40,7 @@ public interface OperandFactory { * @throws IllegalArgumentException If the value provided is {@code null}. * @throws UnsupportedOperationException If the type of the value is not supported by the factory. */ + @SuppressWarnings("unchecked") static AbstractPart createOperand(Object value) { if (value == null) { throw new IllegalArgumentException("Cannot create operand from null value"); @@ -45,9 +54,14 @@ static AbstractPart createOperand(Object value) { return new FloatOperand(((Number) value).doubleValue()); } else if (value instanceof Integer || value instanceof Long) { return new IntOperand(((Number) value).longValue()); + } else if (value instanceof List) { + return new ListOperand((List) value); + } else if (value instanceof SortedMap) { + return new MapOperand((SortedMap) value); + } else if (value instanceof Map) { + return new MapOperand(new TreeMap<>((Map) value)); } else { - throw new UnsupportedOperationException(String.format("Cannot create operand from value of type %s, " + - "only String, boolean, float, double, long and integer values are currently supported", + throw new UnsupportedOperationException(String.format("Cannot create operand from value of type %s", value.getClass().getSimpleName())); } } diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index e509b7d..27be6b7 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -289,20 +289,29 @@ private static void validateInRightOperand(AbstractPart left, AbstractPart right private static void inferInTypes(AbstractPart left, AbstractPart right) { if (right.getPartType() == AbstractPart.PartType.BIN_PART) { - ((BinPart) right).updateExp(Exp.Type.LIST); + BinPart rightBin = (BinPart) right; + if (!rightBin.isTypeExplicitlySet()) { + rightBin.updateExp(Exp.Type.LIST); + } else if (rightBin.getExpType() != Exp.Type.LIST) { + throw new DslParseException( + "IN operation requires a List as the right operand"); + } } + inferLeftBinTypeFromList(left, right); + } + + static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { if (left.getPartType() == AbstractPart.PartType.BIN_PART && !((BinPart) left).isTypeExplicitlySet() && right.getPartType() == AbstractPart.PartType.LIST_OPERAND) { - ListOperand listOperand = (ListOperand) right; - Exp.Type inferredType = inferTypeFromListElements(listOperand); + Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); if (inferredType != null) { ((BinPart) left).updateExp(inferredType); } } } - private static Exp.Type inferTypeFromListElements(ListOperand listOperand) { + static Exp.Type inferTypeFromListElements(ListOperand listOperand) { List values = listOperand.getValue(); if (values.isEmpty()) return null; Object first = values.get(0); diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index 5c25d7b..2b2dbec 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -1157,6 +1157,9 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh if (isResolved && List.of(LT, LTEQ, GT, GTEQ, NOTEQ, EQ).contains(expr.getOperationType())) { overrideTypeInfo(expr.getLeft(), expr.getRight()); } + if (isResolved && expr.getOperationType() == IN) { + ExpressionConditionVisitor.inferLeftBinTypeFromList(expr.getLeft(), expr.getRight()); + } } private static void validateInPlaceholderValue(PlaceholderOperand placeholder, diff --git a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java index f4eabce..13f1eec 100644 --- a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java @@ -271,9 +271,125 @@ void placeholderAsLeftOperand() { PlaceholderValues.of("gold")), expected); } + @Test + void intPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.val(List.of(100, 200, 300))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [100, 200, 300]", + PlaceholderValues.of(100)), expected); + } + + @Test + void floatPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [1.0, 2.0, 3.0]", + PlaceholderValues.of(1.5)), expected); + } + + @Test + void boolPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [true, false]", + PlaceholderValues.of(true)), expected); + } + + @Test + void listPlaceholderAsLeftOperand() { + List> outerList = List.of( + List.of(1, 2, 3), List.of(4, 5, 6)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [[1,2,3], [4,5,6]]", + PlaceholderValues.of(List.of(1, 2, 3))), expected); + } + + @Test + void mapPlaceholderAsLeftOperand() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.val(List.of(map, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [{1: \"a\"}, {2: \"b\"}]", + PlaceholderValues.of(map)), expected); + } + @Test void placeholderAsRightOperand() { - parseFilterExp(ExpressionContext.of("$.name in ?0")); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(List.of("Bob", "Mary"))), expected); + } + + @Test + void intListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(1, 2, 3))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age in ?0", + PlaceholderValues.of(List.of(1, 2, 3))), expected); + } + + @Test + void floatListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("score"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.score in ?0", + PlaceholderValues.of(List.of(1.5, 2.5))), expected); + } + + @Test + void boolListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("isActive"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.isActive in ?0", + PlaceholderValues.of(List.of(true, false))), expected); + } + + @Test + void listOfListsPlaceholderAsRight() { + List> outerList = List.of( + List.of(1, 2, 3), List.of(4, 5, 6)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("listBin"), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.listBin in ?0", + PlaceholderValues.of(outerList)), expected); + } + + @Test + void mapListPlaceholderAsRight() { + TreeMap map1 = new TreeMap<>(); + map1.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + List> mapList = List.of(map1, map2); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("mapBin"), Exp.val(mapList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.mapBin in ?0", + PlaceholderValues.of(mapList)), expected); + } + + @Test + void emptyListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(Collections.emptyList())); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.intBin1 in ?0", + PlaceholderValues.of(Collections.emptyList())), expected); } @Test @@ -371,6 +487,30 @@ void negativeRightOperandMetadata() { .hasMessageContaining("IN operation requires a List as the right operand"); } + @Test + void negExplicitIntTypeOnRightBin() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.tags.get(type: INT)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negExplicitStringTypeOnRightBin() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.tags.get(type: STRING)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void explicitListTypeOnRightBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.listBin("tags")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.intBin1 in $.tags.get(type: LIST)"), expected); + } + @Test void negPlaceholderResolvesToStr() { assertThatThrownBy(() -> parseFilterExp( From 6ea8b80ce4e3ffcce005cbc580f0fbfd2356c990 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 20:16:11 +0100 Subject: [PATCH 03/14] type validation and empty list support. --- .../antlr4/com/aerospike/dsl/Condition.g4 | 2 + .../visitor/ExpressionConditionVisitor.java | 49 ++++++++++++++++--- .../dsl/expression/InExpressionsTests.java | 32 ++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/main/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index f27bbba..31d09ea 100644 --- a/src/main/antlr4/com/aerospike/dsl/Condition.g4 +++ b/src/main/antlr4/com/aerospike/dsl/Condition.g4 @@ -130,6 +130,8 @@ stringOperand: QUOTED_STRING; QUOTED_STRING: ('\'' (~'\'')* '\'') | ('"' (~'"')* '"'); +// LIST_TYPE_DESIGNATOR is needed here because the lexer tokenizes '[]' as a single token, +// preventing the parser from matching it as '[' ']' for empty list literals (e.g. in "$.bin in []"). listConstant: '[' unaryExpression? (',' unaryExpression)* ']' | LIST_TYPE_DESIGNATOR; orderedMapConstant: '{' mapPairConstant? (',' mapPairConstant)* '}'; diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 27be6b7..5360ced 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -311,16 +311,49 @@ static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { } } + /** + * Infer the Aerospike Exp.Type for a list operand by examining its elements. + *

    + * Assumes/enforces that all non-null elements in the list are of the same + * logical type. If heterogeneous element types are detected, a + * {@link DslParseException} is thrown to avoid silent type mismatches. + */ static Exp.Type inferTypeFromListElements(ListOperand listOperand) { List values = listOperand.getValue(); - if (values.isEmpty()) return null; - Object first = values.get(0); - if (first instanceof String) return Exp.Type.STRING; - if (first instanceof Boolean) return Exp.Type.BOOL; - if (first instanceof Float || first instanceof Double) return Exp.Type.FLOAT; - if (first instanceof Integer || first instanceof Long) return Exp.Type.INT; - if (first instanceof java.util.List) return Exp.Type.LIST; - if (first instanceof java.util.Map) return Exp.Type.MAP; + if (values.isEmpty()) { + return null; + } + Exp.Type inferredType = null; + for (Object value : values) { + if (value == null) { + continue; + } + Exp.Type currentType = inferElementType(value); + if (currentType == null) { + throw new DslParseException( + "Unsupported element type in IN list: " + value.getClass().getName()); + } + if (inferredType == null) { + inferredType = currentType; + } else if (inferredType != currentType) { + throw new DslParseException( + "IN list elements must all be of the same type; found " + + inferredType + " and " + currentType); + } + } + return inferredType; + } + + /** + * Map a single Java object to the corresponding Aerospike Exp.Type. + */ + private static Exp.Type inferElementType(Object element) { + if (element instanceof String) return Exp.Type.STRING; + if (element instanceof Boolean) return Exp.Type.BOOL; + if (element instanceof Float || element instanceof Double) return Exp.Type.FLOAT; + if (element instanceof Integer || element instanceof Long) return Exp.Type.INT; + if (element instanceof java.util.List) return Exp.Type.LIST; + if (element instanceof java.util.Map) return Exp.Type.MAP; return null; } diff --git a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java index 13f1eec..4c20d0c 100644 --- a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java @@ -487,6 +487,38 @@ void negativeRightOperandMetadata() { .hasMessageContaining("IN operation requires a List as the right operand"); } + @Test + void negMixedIntAndStringInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1, \"hello\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedBoolAndIntInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [true, 42]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedFloatAndStringInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1.5, \"hello\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedIntAndFloatInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1, 1.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + @Test void negExplicitIntTypeOnRightBin() { assertThatThrownBy(() -> parseFilterExp( From 04ace7e53a2a4acb2b03076429d3f2f9928891a6 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 21:03:39 +0100 Subject: [PATCH 04/14] Update IN list explicit-type validation and tests --- .../dsl/parts/operand/OperandFactory.java | 8 +- .../visitor/ExpressionConditionVisitor.java | 21 ++- .../dsl/expression/InExpressionsTests.java | 128 ++++++++++++++++++ .../parsedExpression/PlaceholdersTests.java | 1 + 4 files changed, 150 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java index 638d511..e060f43 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -1,5 +1,6 @@ package com.aerospike.dsl.parts.operand; +import com.aerospike.dsl.DslParseException; import com.aerospike.dsl.parts.AbstractPart; import java.util.List; @@ -59,7 +60,12 @@ static AbstractPart createOperand(Object value) { } else if (value instanceof SortedMap) { return new MapOperand((SortedMap) value); } else if (value instanceof Map) { - return new MapOperand(new TreeMap<>((Map) value)); + try { + return new MapOperand(new TreeMap<>((Map) value)); + } catch (ClassCastException e) { + throw new DslParseException( + "Map keys must be mutually comparable for operand creation", e); + } } else { throw new UnsupportedOperationException(String.format("Cannot create operand from value of type %s", value.getClass().getSimpleName())); diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 5360ced..cb848d1 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -35,6 +35,7 @@ import java.util.TreeMap; import static com.aerospike.dsl.util.ParsingUtils.*; +import static com.aerospike.dsl.util.ValidationUtils.validateComparableTypes; import static com.aerospike.dsl.visitor.VisitorUtils.*; public class ExpressionConditionVisitor extends ConditionBaseVisitor { @@ -301,13 +302,19 @@ private static void inferInTypes(AbstractPart left, AbstractPart right) { } static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { - if (left.getPartType() == AbstractPart.PartType.BIN_PART - && !((BinPart) left).isTypeExplicitlySet() - && right.getPartType() == AbstractPart.PartType.LIST_OPERAND) { - Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); - if (inferredType != null) { - ((BinPart) left).updateExp(inferredType); - } + if (left.getPartType() != AbstractPart.PartType.BIN_PART + || right.getPartType() != AbstractPart.PartType.LIST_OPERAND) { + return; + } + BinPart leftBin = (BinPart) left; + Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); + if (inferredType == null) { + return; + } + if (!leftBin.isTypeExplicitlySet()) { + leftBin.updateExp(inferredType); + } else { + validateComparableTypes(leftBin.getExpType(), inferredType); } } diff --git a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java index 4c20d0c..bdd9ea0 100644 --- a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java @@ -543,6 +543,134 @@ void explicitListTypeOnRightBin() { ExpressionContext.of("$.intBin1 in $.tags.get(type: LIST)"), expected); } + @Test + void explicitIntInIntList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(1, 2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age.get(type: INT) in [1, 2]"), expected); + } + + @Test + void explicitStringInStringList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("a"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: STRING) in [\"a\"]"), expected); + } + + @Test + void explicitIntCompatibleWithFloatList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: INT) in [1.5, 2.5]"), expected); + } + + @Test + void negExplicitStringInIntList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [1, 2]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitIntInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.age.get(type: INT) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInIntList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [1, 2]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void explicitFloatCompatibleWithIntList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(1, 2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in [1, 2]"), expected); + } + + @Test + void explicitFloatInFloatList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in [1.5, 2.5]"), expected); + } + + @Test + void explicitBoolInBoolList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("flag"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.flag.get(type: BOOL) in [true, false]"), expected); + } + + @Test + void negExplicitIntInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: INT) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitFloatInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: FLOAT) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitFloatInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: FLOAT) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitStringInFloatList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [1.5, 2.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitStringInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInFloatList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [1.5, 2.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + @Test void negPlaceholderResolvesToStr() { assertThatThrownBy(() -> parseFilterExp( diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java index 241a90d..1e05f0b 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java @@ -314,4 +314,5 @@ void binLogical_EXCL_no_indexes() { TestUtils.parseDslExpressionAndCompare(ExpressionContext.of("exclusive($.hand == ?0, $.pun == ?1)", PlaceholderValues.of("stand", "done")), filter, exp); } + } From 525a4663be7a6883c73761ce98119f70f12cb13f Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 23:04:46 +0100 Subject: [PATCH 05/14] Refactoring --- .../antlr4/com/aerospike/dsl/Condition.g4 | 4 +- .../dsl/parts/cdt/list/ListValue.java | 13 +- .../dsl/parts/cdt/list/ListValueList.java | 18 +- .../aerospike/dsl/parts/cdt/map/MapKey.java | 3 + .../aerospike/dsl/parts/cdt/map/MapValue.java | 13 +- .../dsl/parts/cdt/map/MapValueList.java | 18 +- .../dsl/parts/operand/OperandFactory.java | 2 +- .../com/aerospike/dsl/util/ParsingUtils.java | 23 + .../visitor/ExpressionConditionVisitor.java | 85 +-- .../aerospike/dsl/visitor/VisitorUtils.java | 87 ++- .../aerospike/dsl/expression/InBinTests.java | 90 +++ .../dsl/expression/InCompositeTests.java | 97 +++ .../dsl/expression/InExplicitTypeTests.java | 177 +++++ .../dsl/expression/InExpressionsTests.java | 714 ------------------ .../expression/InGrammarConflictTests.java | 105 +++ .../dsl/expression/InLiteralTests.java | 174 +++++ .../dsl/expression/InNegativeTests.java | 130 ++++ .../dsl/expression/InPlaceholderTests.java | 156 ++++ .../parsedExpression/PlaceholdersTests.java | 7 + .../parts/operand/OperandFactoryTests.java | 31 + 20 files changed, 1113 insertions(+), 834 deletions(-) create mode 100644 src/test/java/com/aerospike/dsl/expression/InBinTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InCompositeTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java delete mode 100644 src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InLiteralTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InNegativeTests.java create mode 100644 src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java create mode 100644 src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java diff --git a/src/main/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index 31d09ea..da469b0 100644 --- a/src/main/antlr4/com/aerospike/dsl/Condition.g4 +++ b/src/main/antlr4/com/aerospike/dsl/Condition.g4 @@ -218,7 +218,7 @@ PATH_FUNCTION_CDT_RETURN_TYPE | 'REVERSE_RANK' ; -binPart: NAME_IDENTIFIER; +binPart: NAME_IDENTIFIER | IN; mapPart : MAP_TYPE_DESIGNATOR @@ -241,6 +241,7 @@ MAP_TYPE_DESIGNATOR: '{}'; mapKey : NAME_IDENTIFIER | QUOTED_STRING + | IN ; mapValue: '{=' valueIdentifier '}'; @@ -493,6 +494,7 @@ valueIdentifier : NAME_IDENTIFIER | QUOTED_STRING | signedInt + | IN ; valueListIdentifier: valueIdentifier ',' valueIdentifier (',' valueIdentifier)*; diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValue.java b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValue.java index 291e128..ed5064b 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValue.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValue.java @@ -7,8 +7,7 @@ import com.aerospike.dsl.client.exp.ListExp; import com.aerospike.dsl.parts.path.BasePath; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import static com.aerospike.dsl.util.ParsingUtils.parseValueIdentifier; public class ListValue extends ListPart { private final Object value; @@ -19,15 +18,7 @@ public ListValue(Object value) { } public static ListValue from(ConditionParser.ListValueContext ctx) { - Object listValue = null; - if (ctx.valueIdentifier().NAME_IDENTIFIER() != null) { - listValue = ctx.valueIdentifier().NAME_IDENTIFIER().getText(); - } else if (ctx.valueIdentifier().QUOTED_STRING() != null) { - listValue = unquote(ctx.valueIdentifier().QUOTED_STRING().getText()); - } else if (ctx.valueIdentifier().signedInt() != null) { - listValue = parseSignedInt(ctx.valueIdentifier().signedInt()); - } - return new ListValue(listValue); + return new ListValue(parseValueIdentifier(ctx.valueIdentifier())); } @Override diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueList.java b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueList.java index ecb9b74..7ebf38d 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueList.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueList.java @@ -8,10 +8,9 @@ import com.aerospike.dsl.client.exp.ListExp; import com.aerospike.dsl.parts.path.BasePath; -import java.util.List; +import com.aerospike.dsl.util.ParsingUtils; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import java.util.List; public class ListValueList extends ListPart { private final boolean isInverted; @@ -32,16 +31,9 @@ public static ListValueList from(ConditionParser.ListValueListContext ctx) { valueList != null ? valueList.valueListIdentifier() : invertedValueList.valueListIdentifier(); boolean isInverted = valueList == null; - List valueListObjects = list.valueIdentifier().stream().map( - listValue -> { - if (listValue.NAME_IDENTIFIER() != null) { - return listValue.NAME_IDENTIFIER().getText(); - } else if (listValue.QUOTED_STRING() != null) { - return unquote(listValue.QUOTED_STRING().getText()); - } - return parseSignedInt(listValue.signedInt()); - } - ).toList(); + List valueListObjects = list.valueIdentifier().stream() + .map(ParsingUtils::parseValueIdentifier) + .toList(); return new ListValueList(isInverted, valueListObjects); } diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java index 4c9de26..c4c49a0 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java @@ -25,6 +25,9 @@ public static MapKey from(ConditionParser.MapKeyContext ctx) { if (ctx.NAME_IDENTIFIER() != null) { return new MapKey(ctx.NAME_IDENTIFIER().getText()); } + if (ctx.IN() != null) { + return new MapKey(ctx.IN().getText()); + } throw new DslParseException("Could not translate MapKey from ctx: %s".formatted(ctx)); } diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValue.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValue.java index a067ca9..2cf1bdc 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValue.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValue.java @@ -7,8 +7,7 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import static com.aerospike.dsl.util.ParsingUtils.parseValueIdentifier; public class MapValue extends MapPart { private final Object value; @@ -19,15 +18,7 @@ public MapValue(Object value) { } public static MapValue from(ConditionParser.MapValueContext ctx) { - Object mapValue = null; - if (ctx.valueIdentifier().NAME_IDENTIFIER() != null) { - mapValue = ctx.valueIdentifier().NAME_IDENTIFIER().getText(); - } else if (ctx.valueIdentifier().QUOTED_STRING() != null) { - mapValue = unquote(ctx.valueIdentifier().QUOTED_STRING().getText()); - } else if (ctx.valueIdentifier().signedInt() != null) { - mapValue = parseSignedInt(ctx.valueIdentifier().signedInt()); - } - return new MapValue(mapValue); + return new MapValue(parseValueIdentifier(ctx.valueIdentifier())); } @Override diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueList.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueList.java index eebcf93..02cbb21 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueList.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueList.java @@ -8,10 +8,9 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; -import java.util.List; +import com.aerospike.dsl.util.ParsingUtils; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import java.util.List; public class MapValueList extends MapPart { private final boolean isInverted; @@ -32,16 +31,9 @@ public static MapValueList from(ConditionParser.MapValueListContext ctx) { valueList != null ? valueList.valueListIdentifier() : invertedValueList.valueListIdentifier(); boolean isInverted = valueList == null; - List valueListObjects = list.valueIdentifier().stream().map( - listValue -> { - if (listValue.NAME_IDENTIFIER() != null) { - return listValue.NAME_IDENTIFIER().getText(); - } else if (listValue.QUOTED_STRING() != null) { - return unquote(listValue.QUOTED_STRING().getText()); - } - return parseSignedInt(listValue.signedInt()); - } - ).toList(); + List valueListObjects = list.valueIdentifier().stream() + .map(ParsingUtils::parseValueIdentifier) + .toList(); return new MapValueList(isInverted, valueListObjects); } diff --git a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java index e060f43..0ef686b 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -62,7 +62,7 @@ static AbstractPart createOperand(Object value) { } else if (value instanceof Map) { try { return new MapOperand(new TreeMap<>((Map) value)); - } catch (ClassCastException e) { + } catch (ClassCastException | NullPointerException e) { throw new DslParseException( "Map keys must be mutually comparable for operand creation", e); } diff --git a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index 0ed8b2b..2f067f4 100644 --- a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java @@ -91,6 +91,29 @@ private static BigInteger parseUnsignedIntegerLiteral(String text) { } } + /** + * Extracts a typed value from a {@code valueIdentifier} parser rule context. + * Handles NAME_IDENTIFIER, QUOTED_STRING, IN keyword (as literal text), and signedInt. + * + * @param ctx The valueIdentifier context from the parser + * @return The parsed value as String or Integer + */ + public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { + if (ctx.NAME_IDENTIFIER() != null) { + return ctx.NAME_IDENTIFIER().getText(); + } + if (ctx.QUOTED_STRING() != null) { + return unquote(ctx.QUOTED_STRING().getText()); + } + if (ctx.IN() != null) { + return ctx.IN().getText(); + } + if (ctx.signedInt() != null) { + return parseSignedInt(ctx.signedInt()); + } + throw new DslParseException("Could not parse valueIdentifier from ctx: %s".formatted(ctx.getText())); + } + /** * Get the string inside the quotes. * diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index cb848d1..177a564 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -30,12 +30,12 @@ import org.antlr.v4.runtime.tree.RuleNode; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.SortedMap; import java.util.TreeMap; import static com.aerospike.dsl.util.ParsingUtils.*; -import static com.aerospike.dsl.util.ValidationUtils.validateComparableTypes; import static com.aerospike.dsl.visitor.VisitorUtils.*; public class ExpressionConditionVisitor extends ConditionBaseVisitor { @@ -297,71 +297,17 @@ private static void inferInTypes(AbstractPart left, AbstractPart right) { throw new DslParseException( "IN operation requires a List as the right operand"); } - } - inferLeftBinTypeFromList(left, right); - } - - static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { - if (left.getPartType() != AbstractPart.PartType.BIN_PART - || right.getPartType() != AbstractPart.PartType.LIST_OPERAND) { - return; - } - BinPart leftBin = (BinPart) left; - Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); - if (inferredType == null) { - return; - } - if (!leftBin.isTypeExplicitlySet()) { - leftBin.updateExp(inferredType); - } else { - validateComparableTypes(leftBin.getExpType(), inferredType); - } - } - - /** - * Infer the Aerospike Exp.Type for a list operand by examining its elements. - *

    - * Assumes/enforces that all non-null elements in the list are of the same - * logical type. If heterogeneous element types are detected, a - * {@link DslParseException} is thrown to avoid silent type mismatches. - */ - static Exp.Type inferTypeFromListElements(ListOperand listOperand) { - List values = listOperand.getValue(); - if (values.isEmpty()) { - return null; - } - Exp.Type inferredType = null; - for (Object value : values) { - if (value == null) { - continue; - } - Exp.Type currentType = inferElementType(value); - if (currentType == null) { - throw new DslParseException( - "Unsupported element type in IN list: " + value.getClass().getName()); - } - if (inferredType == null) { - inferredType = currentType; - } else if (inferredType != currentType) { - throw new DslParseException( - "IN list elements must all be of the same type; found " - + inferredType + " and " + currentType); + } else if (right.getPartType() == AbstractPart.PartType.PATH_OPERAND) { + Path rightPath = (Path) right; + if (rightPath.getPathFunction() != null) { + Exp.Type pathType = rightPath.getPathFunction().getBinType(); + if (pathType != null && pathType != Exp.Type.LIST) { + throw new DslParseException( + "IN operation requires a List as the right operand"); + } } } - return inferredType; - } - - /** - * Map a single Java object to the corresponding Aerospike Exp.Type. - */ - private static Exp.Type inferElementType(Object element) { - if (element instanceof String) return Exp.Type.STRING; - if (element instanceof Boolean) return Exp.Type.BOOL; - if (element instanceof Float || element instanceof Double) return Exp.Type.FLOAT; - if (element instanceof Integer || element instanceof Long) return Exp.Type.INT; - if (element instanceof java.util.List) return Exp.Type.LIST; - if (element instanceof java.util.Map) return Exp.Type.MAP; - return null; + VisitorUtils.inferLeftBinTypeFromList(left, right); } @Override @@ -673,7 +619,13 @@ public AbstractPart visitMetadata(ConditionParser.MetadataContext ctx) { @Override public AbstractPart visitBinPart(ConditionParser.BinPartContext ctx) { - return new BinPart(ctx.NAME_IDENTIFIER().getText()); + if (ctx.NAME_IDENTIFIER() != null) { + return new BinPart(ctx.NAME_IDENTIFIER().getText()); + } + if (ctx.IN() != null) { + return new BinPart(ctx.IN().getText()); + } + throw new DslParseException("Could not parse binPart from ctx: %s".formatted(ctx.getText())); } @Override @@ -683,6 +635,9 @@ public AbstractPart visitOperandExpression(ConditionParser.OperandExpressionCont @Override public AbstractPart visitListConstant(ConditionParser.ListConstantContext ctx) { + if (ctx.LIST_TYPE_DESIGNATOR() != null) { + return new ListOperand(Collections.emptyList()); + } return readChildrenIntoListOperand(ctx); } diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index 2b2dbec..2554343 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -20,6 +20,7 @@ import com.aerospike.dsl.parts.controlstructure.WithStructure; import com.aerospike.dsl.parts.operand.FunctionArgs; import com.aerospike.dsl.parts.operand.IntOperand; +import com.aerospike.dsl.parts.operand.ListOperand; import com.aerospike.dsl.parts.operand.MetadataOperand; import com.aerospike.dsl.parts.operand.PlaceholderOperand; import com.aerospike.dsl.parts.operand.StringOperand; @@ -1142,13 +1143,12 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh validateInPlaceholderValue((PlaceholderOperand) expr.getRight(), placeholderValues); } - // Resolve left placeholder and replace it with the resolved operand if (leftIsPlaceholder) { PlaceholderOperand placeholder = (PlaceholderOperand) expr.getLeft(); expr.setLeft(placeholder.resolve(placeholderValues)); isResolved = true; - } else if (rightIsPlaceholder) { - // Resolve right placeholder and replace it with the resolved operand + } + if (rightIsPlaceholder) { PlaceholderOperand placeholder = (PlaceholderOperand) expr.getRight(); expr.setRight(placeholder.resolve(placeholderValues)); isResolved = true; @@ -1158,18 +1158,95 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh overrideTypeInfo(expr.getLeft(), expr.getRight()); } if (isResolved && expr.getOperationType() == IN) { - ExpressionConditionVisitor.inferLeftBinTypeFromList(expr.getLeft(), expr.getRight()); + inferLeftBinTypeFromList(expr.getLeft(), expr.getRight()); } } private static void validateInPlaceholderValue(PlaceholderOperand placeholder, PlaceholderValues placeholderValues) { Object value = placeholderValues.getValue(placeholder.getIndex()); - if (!(value instanceof java.util.List)) { + if (!(value instanceof List)) { throw new DslParseException("IN operation requires a List as the right operand"); } } + static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { + if (left.getPartType() != BIN_PART + || right.getPartType() != LIST_OPERAND) { + return; + } + BinPart leftBin = (BinPart) left; + Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); + if (inferredType == null) { + return; + } + if (!leftBin.isTypeExplicitlySet()) { + leftBin.updateExp(inferredType); + } else { + validateComparableTypes(leftBin.getExpType(), inferredType); + } + } + + /** + * Infer the Aerospike Exp.Type for a list operand by examining its elements. + *

    + * Assumes/enforces that all non-null elements in the list are of the same + * logical type. If heterogeneous element types are detected, a + * {@link DslParseException} is thrown to avoid silent type mismatches. + * + * @return the inferred type, or {@code null} if the list is empty + */ + static Exp.Type inferTypeFromListElements(ListOperand listOperand) { + List values = listOperand.getValue(); + if (values.isEmpty()) { + return null; + } + Exp.Type inferredType = null; + for (Object value : values) { + if (value == null) { + continue; + } + Exp.Type currentType = inferElementType(value); + if (currentType == null) { + throw new DslParseException( + "Unsupported element type in IN list: " + value.getClass().getName()); + } + if (inferredType == null) { + inferredType = currentType; + } else if (inferredType != currentType) { + throw new DslParseException( + "IN list elements must all be of the same type; found " + + inferredType + " and " + currentType); + } + } + return inferredType; + } + + /** + * Map a single Java object to the corresponding Aerospike Exp.Type. + */ + private static Exp.Type inferElementType(Object element) { + if (element instanceof String) { + return Exp.Type.STRING; + } + if (element instanceof Boolean) { + return Exp.Type.BOOL; + } + if (element instanceof Float || element instanceof Double) { + return Exp.Type.FLOAT; + } + if (element instanceof Integer || element instanceof Long) { + return Exp.Type.INT; + } + if (element instanceof List) { + return Exp.Type.LIST; + } + if (element instanceof Map) { + return Exp.Type.MAP; + } + return null; + } + /** * Builds an array of {@link CTX} for a given path. * This is the main entry point for building context based on the parsed expression tree. diff --git a/src/test/java/com/aerospike/dsl/expression/InBinTests.java b/src/test/java/com/aerospike/dsl/expression/InBinTests.java new file mode 100644 index 0000000..ed60246 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InBinTests.java @@ -0,0 +1,90 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.client.Value; +import com.aerospike.dsl.client.cdt.CTX; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.cdt.MapReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import com.aerospike.dsl.client.exp.MapExp; +import org.junit.jupiter.api.Test; + +import java.util.TreeMap; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; + +class InBinTests { + + @Test + void stringLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.listBin("allowedStatuses")); + parseFilterExpressionAndCompare( + ExpressionContext.of("\"gold\" in $.allowedStatuses"), expected); + } + + @Test + void intLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.listBin("allowedValues")); + parseFilterExpressionAndCompare( + ExpressionContext.of("100 in $.allowedValues"), expected); + } + + @Test + void floatLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.listBin("scores")); + parseFilterExpressionAndCompare( + ExpressionContext.of("1.5 in $.scores"), expected); + } + + @Test + void boolLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.listBin("flags")); + parseFilterExpressionAndCompare( + ExpressionContext.of("true in $.flags"), expected); + } + + @Test + void listLiteralInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(java.util.List.of(1, 2, 3)), Exp.listBin("listOfLists")); + parseFilterExpressionAndCompare( + ExpressionContext.of("[1, 2, 3] in $.listOfLists"), expected); + } + + @Test + void mapLiteralInBin() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.listBin("mapItems")); + parseFilterExpressionAndCompare( + ExpressionContext.of("{1: \"a\"} in $.mapItems"), expected); + } + + @Test + void binInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("itemType"), Exp.listBin("allowedItems")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.itemType in $.allowedItems"), expected); + } + + @Test + void binInNestedPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin"), + MapExp.getByKey( + MapReturnType.VALUE, + Exp.Type.STRING, + Exp.val("allowedNames"), + Exp.mapBin("rooms"), + CTX.mapKey(Value.get("config")))); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.intBin in $.rooms.config.allowedNames"), expected); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java new file mode 100644 index 0000000..84c223a --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java @@ -0,0 +1,97 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; + +class InCompositeTests { + + @Test + void inWithAndOperator() { + Exp expected = Exp.and( + Exp.gt(Exp.intBin("cost"), Exp.val(50)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("status"), Exp.val(List.of("active", "pending")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.cost > 50 and $.status in [\"active\", \"pending\"]"), expected); + } + + @Test + void inWithOrOperator() { + Exp expected = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("status"), Exp.val(List.of("active"))), + Exp.gt(Exp.intBin("priority"), Exp.val(5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.status in [\"active\"] or $.priority > 5"), expected); + } + + @Test + void complexExpressionWithIn() { + Exp expected = Exp.and( + Exp.gt(Exp.intBin("cost"), Exp.val(50)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("status"), Exp.listBin("allowedStatuses")), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("available"), Exp.listBin("bookableStates"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.cost > 50 and $.status in $.allowedStatuses" + + " and \"available\" in $.bookableStates"), expected); + } + + @Test + void inWithParentheses() { + Exp expected = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.gt(Exp.intBin("age"), Exp.val(18))); + parseFilterExpressionAndCompare( + ExpressionContext.of("($.name in [\"Bob\"]) and $.age > 18"), expected); + } + + @Test + void inInsideWithStructure() { + Exp expected = Exp.let( + Exp.def("allowed", Exp.val(List.of("Bob", "Mary"))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.var("allowed"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(allowed = [\"Bob\", \"Mary\"])" + + " do ($.name.get(type: STRING) in ${allowed})"), expected); + } + + @Test + void inInsideWhenCondition() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.val("VIP"), + Exp.val("regular")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.name.get(type: STRING) in [\"Bob\"] => \"VIP\"," + + " default => \"regular\")"), expected); + } + + @Test + void notWrappingIn() { + Exp expected = Exp.not(ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("not($.name in [\"Bob\", \"Mary\"])"), expected); + } + + @Test + void arithmeticExprAsLeftIn() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.add(Exp.intBin("a"), Exp.val(5)), + Exp.val(List.of(10, 20, 30))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.a + 5 in [10, 20, 30]"), expected); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java new file mode 100644 index 0000000..64ae86e --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java @@ -0,0 +1,177 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.DslParseException; +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExp; +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InExplicitTypeTests { + + @Test + void explicitListTypeOnRightBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.listBin("tags")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.intBin1 in $.tags.get(type: LIST)"), expected); + } + + @Test + void explicitIntInIntList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(1, 2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age.get(type: INT) in [1, 2]"), expected); + } + + @Test + void explicitStringInStringList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("a"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: STRING) in [\"a\"]"), expected); + } + + @Test + void explicitIntCompatibleWithFloatList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: INT) in [1.5, 2.5]"), expected); + } + + @Test + void explicitFloatCompatibleWithIntList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(1, 2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in [1, 2]"), expected); + } + + @Test + void explicitFloatInFloatList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in [1.5, 2.5]"), expected); + } + + @Test + void explicitBoolInBoolList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("flag"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.flag.get(type: BOOL) in [true, false]"), expected); + } + + @Test + void negExplicitIntTypeOnRightBin() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.tags.get(type: INT)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negExplicitStringTypeOnRightBin() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.tags.get(type: STRING)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negNestedPathExplicitStringOnRight() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.tags.nested.get(type: STRING)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negExplicitStringInIntList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [1, 2]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitIntInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.age.get(type: INT) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInIntList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [1, 2]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitIntInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: INT) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitFloatInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: FLOAT) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitFloatInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.val.get(type: FLOAT) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitStringInFloatList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [1.5, 2.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitStringInBoolList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in [true, false]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInStringList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [\"a\", \"b\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } + + @Test + void negExplicitBoolInFloatList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.flag.get(type: BOOL) in [1.5, 2.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare"); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java b/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java deleted file mode 100644 index bdd9ea0..0000000 --- a/src/test/java/com/aerospike/dsl/expression/InExpressionsTests.java +++ /dev/null @@ -1,714 +0,0 @@ -package com.aerospike.dsl.expression; - -import com.aerospike.dsl.DslParseException; -import com.aerospike.dsl.ExpressionContext; -import com.aerospike.dsl.PlaceholderValues; -import com.aerospike.dsl.client.cdt.ListReturnType; -import com.aerospike.dsl.client.exp.Exp; -import com.aerospike.dsl.client.exp.ListExp; -import org.junit.jupiter.api.Test; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - -import static com.aerospike.dsl.util.TestUtils.parseFilterExp; -import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class InExpressionsTests { - - @Test - void stringLiteralInListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val("gold"), Exp.val(List.of("gold", "silver"))); - parseFilterExpressionAndCompare(ExpressionContext.of("\"gold\" in [\"gold\", \"silver\"]"), expected); - } - - @Test - void intLiteralInListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(100), Exp.val(List.of(100, 200, 300))); - parseFilterExpressionAndCompare(ExpressionContext.of("100 in [100, 200, 300]"), expected); - } - - @Test - void floatLiteralInListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); - parseFilterExpressionAndCompare(ExpressionContext.of("1.5 in [1.0, 2.0, 3.0]"), expected); - } - - @Test - void boolLiteralInListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(true), Exp.val(List.of(true, false))); - parseFilterExpressionAndCompare(ExpressionContext.of("true in [true, false]"), expected); - } - - @Test - void listLiteralInListOfLists() { - List> outerList = List.of( - List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); - parseFilterExpressionAndCompare( - ExpressionContext.of("[1,2,3] in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); - } - - @Test - void listBinInListOfLists() { - List> outerList = List.of( - List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.listBin("listBin"), Exp.val(outerList)); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.listBin in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); - } - - @Test - void mapLiteralInListOfMaps() { - TreeMap map = new TreeMap<>(); - map.put(1, "a"); - TreeMap map2 = new TreeMap<>(); - map2.put(2, "b"); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(map), Exp.val(List.of(map, map2))); - parseFilterExpressionAndCompare( - ExpressionContext.of("{1: \"a\"} in [{1: \"a\"}, {2: \"b\"}]"), expected); - } - - @Test - void mapBinInListOfMaps() { - TreeMap map1 = new TreeMap<>(); - map1.put(1, "a"); - TreeMap map2 = new TreeMap<>(); - map2.put(2, "b"); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.mapBin("mapBin"), Exp.val(List.of(map1, map2))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.mapBin in [{1: \"a\"}, {2: \"b\"}]"), expected); - } - - @Test - void binInStringListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.name in [\"Bob\", \"Mary\"]"), expected); - } - - @Test - void binInIntListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("age"), Exp.val(List.of(18, 21, 65))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.age in [18, 21, 65]"), expected); - } - - @Test - void binInFloatListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.floatBin("score"), Exp.val(List.of(1.0, 2.5))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.score in [1.0, 2.5]"), expected); - } - - @Test - void binInBoolListLiteral() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.boolBin("isActive"), Exp.val(List.of(true, false))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.isActive in [true, false]"), expected); - } - - @Test - void stringLiteralInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val("gold"), Exp.listBin("allowedStatuses")); - parseFilterExpressionAndCompare( - ExpressionContext.of("\"gold\" in $.allowedStatuses"), expected); - } - - @Test - void intLiteralInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(100), Exp.listBin("allowedValues")); - parseFilterExpressionAndCompare( - ExpressionContext.of("100 in $.allowedValues"), expected); - } - - @Test - void floatLiteralInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(1.5), Exp.listBin("scores")); - parseFilterExpressionAndCompare( - ExpressionContext.of("1.5 in $.scores"), expected); - } - - @Test - void boolLiteralInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(true), Exp.listBin("flags")); - parseFilterExpressionAndCompare( - ExpressionContext.of("true in $.flags"), expected); - } - - @Test - void listLiteralInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(List.of(1, 2, 3)), Exp.listBin("listOfLists")); - parseFilterExpressionAndCompare( - ExpressionContext.of("[1, 2, 3] in $.listOfLists"), expected); - } - - @Test - void mapLiteralInBin() { - TreeMap map = new TreeMap<>(); - map.put(1, "a"); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(map), Exp.listBin("mapItems")); - parseFilterExpressionAndCompare( - ExpressionContext.of("{1: \"a\"} in $.mapItems"), expected); - } - - @Test - void binInBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("itemType"), Exp.listBin("allowedItems")); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.itemType in $.allowedItems"), expected); - } - - @Test - void nestedPathInListLiteral() { - parseFilterExp(ExpressionContext.of( - "$.rooms.room1.rates.rateType in [\"RACK_RATE\", \"DISCOUNT\"]")); - } - - @Test - void caseInsensitiveIn() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob"))); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name IN [\"Bob\"]"), expected); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name In [\"Bob\"]"), expected); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name iN [\"Bob\"]"), expected); - } - - @Test - void inWithAndOperator() { - Exp expected = Exp.and( - Exp.gt(Exp.intBin("cost"), Exp.val(50)), - ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("status"), Exp.val(List.of("active", "pending")))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.cost > 50 and $.status in [\"active\", \"pending\"]"), expected); - } - - @Test - void inWithOrOperator() { - Exp expected = Exp.or( - ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("status"), Exp.val(List.of("active"))), - Exp.gt(Exp.intBin("priority"), Exp.val(5))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.status in [\"active\"] or $.priority > 5"), expected); - } - - @Test - void complexExpressionWithIn() { - Exp expected = Exp.and( - Exp.gt(Exp.intBin("cost"), Exp.val(50)), - ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("status"), Exp.listBin("allowedStatuses")), - ListExp.getByValue(ListReturnType.EXISTS, - Exp.val("available"), Exp.listBin("bookableStates"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.cost > 50 and $.status in $.allowedStatuses" + - " and \"available\" in $.bookableStates"), expected); - } - - @Test - void inWithParentheses() { - Exp expected = Exp.and( - ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob"))), - Exp.gt(Exp.intBin("age"), Exp.val(18))); - parseFilterExpressionAndCompare( - ExpressionContext.of("($.name in [\"Bob\"]) and $.age > 18"), expected); - } - - @Test - void inInsideWithStructure() { - Exp expected = Exp.let( - Exp.def("allowed", Exp.val(List.of("Bob", "Mary"))), - ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.var("allowed"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("with(allowed = [\"Bob\", \"Mary\"])" + - " do ($.name.get(type: STRING) in ${allowed})"), expected); - } - - @Test - void inInsideWhenCondition() { - Exp expected = Exp.cond( - ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob"))), - Exp.val("VIP"), - Exp.val("regular")); - parseFilterExpressionAndCompare( - ExpressionContext.of("when($.name.get(type: STRING) in [\"Bob\"] => \"VIP\"," + - " default => \"regular\")"), expected); - } - - @Test - void placeholderAsLeftOperand() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val("gold"), Exp.val(List.of("gold", "silver"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [\"gold\", \"silver\"]", - PlaceholderValues.of("gold")), expected); - } - - @Test - void intPlaceholderAsLeftOperand() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(100), Exp.val(List.of(100, 200, 300))); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [100, 200, 300]", - PlaceholderValues.of(100)), expected); - } - - @Test - void floatPlaceholderAsLeftOperand() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [1.0, 2.0, 3.0]", - PlaceholderValues.of(1.5)), expected); - } - - @Test - void boolPlaceholderAsLeftOperand() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(true), Exp.val(List.of(true, false))); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [true, false]", - PlaceholderValues.of(true)), expected); - } - - @Test - void listPlaceholderAsLeftOperand() { - List> outerList = List.of( - List.of(1, 2, 3), List.of(4, 5, 6)); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [[1,2,3], [4,5,6]]", - PlaceholderValues.of(List.of(1, 2, 3))), expected); - } - - @Test - void mapPlaceholderAsLeftOperand() { - TreeMap map = new TreeMap<>(); - map.put(1, "a"); - TreeMap map2 = new TreeMap<>(); - map2.put(2, "b"); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.val(map), Exp.val(List.of(map, map2))); - parseFilterExpressionAndCompare( - ExpressionContext.of("?0 in [{1: \"a\"}, {2: \"b\"}]", - PlaceholderValues.of(map)), expected); - } - - @Test - void placeholderAsRightOperand() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.name in ?0", - PlaceholderValues.of(List.of("Bob", "Mary"))), expected); - } - - @Test - void intListPlaceholderAsRight() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("age"), Exp.val(List.of(1, 2, 3))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.age in ?0", - PlaceholderValues.of(List.of(1, 2, 3))), expected); - } - - @Test - void floatListPlaceholderAsRight() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.floatBin("score"), Exp.val(List.of(1.5, 2.5))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.score in ?0", - PlaceholderValues.of(List.of(1.5, 2.5))), expected); - } - - @Test - void boolListPlaceholderAsRight() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.boolBin("isActive"), Exp.val(List.of(true, false))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.isActive in ?0", - PlaceholderValues.of(List.of(true, false))), expected); - } - - @Test - void listOfListsPlaceholderAsRight() { - List> outerList = List.of( - List.of(1, 2, 3), List.of(4, 5, 6)); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.listBin("listBin"), Exp.val(outerList)); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.listBin in ?0", - PlaceholderValues.of(outerList)), expected); - } - - @Test - void mapListPlaceholderAsRight() { - TreeMap map1 = new TreeMap<>(); - map1.put(1, "a"); - TreeMap map2 = new TreeMap<>(); - map2.put(2, "b"); - List> mapList = List.of(map1, map2); - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.mapBin("mapBin"), Exp.val(mapList)); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.mapBin in ?0", - PlaceholderValues.of(mapList)), expected); - } - - @Test - void emptyListPlaceholderAsRight() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("intBin1"), Exp.val(Collections.emptyList())); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.intBin1 in ?0", - PlaceholderValues.of(Collections.emptyList())), expected); - } - - @Test - void inWithEmptyList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("name"), Exp.val(Collections.emptyList())); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name in []"), expected); - } - - @Test - void inWithSingleElementList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob"))); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name in [\"Bob\"]"), expected); - } - - @Test - void inWithNegativeInts() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("val"), Exp.val(List.of(-1, 0, 1))); - parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1, 0, 1]"), expected); - } - - @Test - void inWithNegativeFloats() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.floatBin("val"), Exp.val(List.of(-1.5, 0.0, 1.5))); - parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1.5, 0.0, 1.5]"), expected); - } - - @Test - void inWithHexBinaryLiterals() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("val"), Exp.val(List.of(255, 5, 42))); - parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [0xFF, 0b101, 42]"), expected); - } - - @Test - void notWrappingIn() { - Exp expected = Exp.not(ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary")))); - parseFilterExpressionAndCompare( - ExpressionContext.of("not($.name in [\"Bob\", \"Mary\"])"), expected); - } - - @Test - void arithmeticExprAsLeftIn() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.add(Exp.intBin("a"), Exp.val(5)), - Exp.val(List.of(10, 20, 30))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.a + 5 in [10, 20, 30]"), expected); - } - - @Test - void negativeRightOperandString() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in \"Bob\""))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negativeRightOperandInt() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 42"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negativeRightOperandFloat() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 1.5"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negativeRightOperandBool() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in true"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negativeRightOperandMap() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in {\"a\": 1}"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negativeRightOperandMetadata() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in $.ttl()"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negMixedIntAndStringInList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.bin in [1, \"hello\"]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN list elements must all be of the same type"); - } - - @Test - void negMixedBoolAndIntInList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.bin in [true, 42]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN list elements must all be of the same type"); - } - - @Test - void negMixedFloatAndStringInList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.bin in [1.5, \"hello\"]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN list elements must all be of the same type"); - } - - @Test - void negMixedIntAndFloatInList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.bin in [1, 1.5]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN list elements must all be of the same type"); - } - - @Test - void negExplicitIntTypeOnRightBin() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in $.tags.get(type: INT)"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negExplicitStringTypeOnRightBin() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in $.tags.get(type: STRING)"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void explicitListTypeOnRightBin() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("intBin1"), Exp.listBin("tags")); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.intBin1 in $.tags.get(type: LIST)"), expected); - } - - @Test - void explicitIntInIntList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("age"), Exp.val(List.of(1, 2))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.age.get(type: INT) in [1, 2]"), expected); - } - - @Test - void explicitStringInStringList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.stringBin("name"), Exp.val(List.of("a"))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.name.get(type: STRING) in [\"a\"]"), expected); - } - - @Test - void explicitIntCompatibleWithFloatList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("val"), Exp.val(List.of(1.5, 2.5))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.val.get(type: INT) in [1.5, 2.5]"), expected); - } - - @Test - void negExplicitStringInIntList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name.get(type: STRING) in [1, 2]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitIntInStringList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.age.get(type: INT) in [\"a\", \"b\"]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitBoolInIntList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.flag.get(type: BOOL) in [1, 2]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void explicitFloatCompatibleWithIntList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.floatBin("val"), Exp.val(List.of(1, 2))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.val.get(type: FLOAT) in [1, 2]"), expected); - } - - @Test - void explicitFloatInFloatList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.floatBin("val"), Exp.val(List.of(1.5, 2.5))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.val.get(type: FLOAT) in [1.5, 2.5]"), expected); - } - - @Test - void explicitBoolInBoolList() { - Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.boolBin("flag"), Exp.val(List.of(true, false))); - parseFilterExpressionAndCompare( - ExpressionContext.of("$.flag.get(type: BOOL) in [true, false]"), expected); - } - - @Test - void negExplicitIntInBoolList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.val.get(type: INT) in [true, false]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitFloatInStringList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.val.get(type: FLOAT) in [\"a\", \"b\"]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitFloatInBoolList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.val.get(type: FLOAT) in [true, false]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitStringInFloatList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name.get(type: STRING) in [1.5, 2.5]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitStringInBoolList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name.get(type: STRING) in [true, false]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitBoolInStringList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.flag.get(type: BOOL) in [\"a\", \"b\"]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negExplicitBoolInFloatList() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.flag.get(type: BOOL) in [1.5, 2.5]"))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("Cannot compare"); - } - - @Test - void negPlaceholderResolvesToStr() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", PlaceholderValues.of("Bob")))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negPlaceholderResolvesToInt() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negPlaceholderResolvesToFloat() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negPlaceholderResolvesToBool() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } - - @Test - void negPlaceholderResolvesToMap() { - assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", - PlaceholderValues.of(Map.of("a", 1))))) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); - } -} diff --git a/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java new file mode 100644 index 0000000..88a71e4 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java @@ -0,0 +1,105 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.client.Value; +import com.aerospike.dsl.client.cdt.CTX; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.cdt.MapReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import com.aerospike.dsl.client.exp.MapExp; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; + +class InGrammarConflictTests { + + @Test + void caseInsensitiveIn() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name IN [\"Bob\"]"), expected); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name In [\"Bob\"]"), expected); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name iN [\"Bob\"]"), expected); + } + + @Test + void binNamedInEquality() { + Exp expected = Exp.eq(Exp.intBin("in"), Exp.val(5)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.in == 5"), expected); + } + + @Test + void binNamedInInList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("in"), Exp.val(List.of(1, 2))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.in in [1, 2]"), expected); + } + + @Test + void mapKeyNamedIn() { + Exp expected = Exp.gt( + MapExp.getByKey( + MapReturnType.VALUE, + Exp.Type.INT, + Exp.val("in"), + Exp.mapBin("map"), + CTX.mapKey(Value.get("a"))), + Exp.val(5)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.map.a.in > 5"), expected); + } + + @Test + void simpleMapKeyNamedIn() { + Exp expected = Exp.eq( + MapExp.getByKey( + MapReturnType.VALUE, + Exp.Type.INT, + Exp.val("in"), + Exp.mapBin("list")), + Exp.val(5)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.list.in == 5"), expected); + } + + @Test + void listValueNamedIn() { + Exp expected = Exp.eq( + ListExp.getByValue(ListReturnType.VALUE, + Exp.val("in"), Exp.listBin("listBin")), + Exp.val("hello")); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.listBin.[=in].get(type: STRING) == \"hello\""), expected); + } + + @Test + void listValueNamedInUpperCase() { + Exp expected = Exp.eq( + ListExp.getByValue(ListReturnType.VALUE, + Exp.val("IN"), Exp.listBin("listBin")), + Exp.val("hello")); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.listBin.[=IN].get(type: STRING) == \"hello\""), expected); + } + + @Test + void mapValueNamedIn() { + Exp expected = Exp.eq( + MapExp.getByValue(MapReturnType.VALUE, + Exp.val("in"), Exp.mapBin("mapBin")), + Exp.val("hello")); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.mapBin.{=in}.get(type: STRING) == \"hello\""), expected); + } + + @Test + void mapValueNamedInUpperCase() { + Exp expected = Exp.eq( + MapExp.getByValue(MapReturnType.VALUE, + Exp.val("IN"), Exp.mapBin("mapBin")), + Exp.val("hello")); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.mapBin.{=IN}.get(type: STRING) == \"hello\""), expected); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java b/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java new file mode 100644 index 0000000..c6f0dac --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java @@ -0,0 +1,174 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.client.Value; +import com.aerospike.dsl.client.cdt.CTX; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.cdt.MapReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import com.aerospike.dsl.client.exp.MapExp; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; + +class InLiteralTests { + + @Test + void stringLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare(ExpressionContext.of("\"gold\" in [\"gold\", \"silver\"]"), expected); + } + + @Test + void intLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.val(List.of(100, 200, 300))); + parseFilterExpressionAndCompare(ExpressionContext.of("100 in [100, 200, 300]"), expected); + } + + @Test + void floatLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); + parseFilterExpressionAndCompare(ExpressionContext.of("1.5 in [1.0, 2.0, 3.0]"), expected); + } + + @Test + void boolLiteralInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare(ExpressionContext.of("true in [true, false]"), expected); + } + + @Test + void listLiteralInListOfLists() { + List> outerList = List.of( + List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("[1,2,3] in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); + } + + @Test + void listBinInListOfLists() { + List> outerList = List.of( + List.of(2, 3, 4), List.of(3, 4, 5), List.of(1, 2, 3), List.of(1, 2)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("listBin"), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.listBin in [[2,3,4], [3,4,5], [1,2,3], [1,2]]"), expected); + } + + @Test + void mapLiteralInListOfMaps() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.val(List.of(map, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("{1: \"a\"} in [{1: \"a\"}, {2: \"b\"}]"), expected); + } + + @Test + void mapBinInListOfMaps() { + TreeMap map1 = new TreeMap<>(); + map1.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("mapBin"), Exp.val(List.of(map1, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.mapBin in [{1: \"a\"}, {2: \"b\"}]"), expected); + } + + @Test + void binInStringListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name in [\"Bob\", \"Mary\"]"), expected); + } + + @Test + void binInIntListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(18, 21, 65))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age in [18, 21, 65]"), expected); + } + + @Test + void binInFloatListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("score"), Exp.val(List.of(1.0, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.score in [1.0, 2.5]"), expected); + } + + @Test + void binInBoolListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("isActive"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.isActive in [true, false]"), expected); + } + + @Test + void nestedPathInListLiteral() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + MapExp.getByKey( + MapReturnType.VALUE, + Exp.Type.STRING, + Exp.val("rateType"), + Exp.mapBin("rooms"), + CTX.mapKey(Value.get("room1")), + CTX.mapKey(Value.get("rates"))), + Exp.val(List.of("RACK_RATE", "DISCOUNT"))); + parseFilterExpressionAndCompare(ExpressionContext.of( + "$.rooms.room1.rates.rateType in [\"RACK_RATE\", \"DISCOUNT\"]"), expected); + } + + @Test + void inWithEmptyList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("name"), Exp.val(Collections.emptyList())); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name in []"), expected); + } + + @Test + void inWithSingleElementList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name in [\"Bob\"]"), expected); + } + + @Test + void inWithNegativeInts() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(-1, 0, 1))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1, 0, 1]"), expected); + } + + @Test + void inWithNegativeFloats() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.val(List.of(-1.5, 0.0, 1.5))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [-1.5, 0.0, 1.5]"), expected); + } + + @Test + void inWithHexBinaryLiterals() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.val(List.of(255, 5, 42))); + parseFilterExpressionAndCompare(ExpressionContext.of("$.val in [0xFF, 0b101, 42]"), expected); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java new file mode 100644 index 0000000..edca547 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -0,0 +1,130 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.DslParseException; +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.PlaceholderValues; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExp; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InNegativeTests { + + @Test + void negativeRightOperandString() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in \"Bob\""))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandInt() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 42"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandFloat() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in 1.5"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandBool() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in true"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in {\"a\": 1}"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negativeRightOperandMetadata() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.name in $.ttl()"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negMixedIntAndStringInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1, \"hello\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedBoolAndIntInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [true, 42]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedFloatAndStringInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1.5, \"hello\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedIntAndFloatInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.bin in [1, 1.5]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negPlaceholderResolvesToStr() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of("Bob")))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToInt() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToFloat() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToBool() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negPlaceholderResolvesToMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(Map.of("a", 1))))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } +} diff --git a/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java b/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java new file mode 100644 index 0000000..d9bccd7 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java @@ -0,0 +1,156 @@ +package com.aerospike.dsl.expression; + +import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.PlaceholderValues; +import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.exp.ListExp; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; + +import static com.aerospike.dsl.util.TestUtils.parseFilterExpressionAndCompare; + +class InPlaceholderTests { + + @Test + void placeholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [\"gold\", \"silver\"]", + PlaceholderValues.of("gold")), expected); + } + + @Test + void intPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(100), Exp.val(List.of(100, 200, 300))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [100, 200, 300]", + PlaceholderValues.of(100)), expected); + } + + @Test + void floatPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1.5), Exp.val(List.of(1.0, 2.0, 3.0))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [1.0, 2.0, 3.0]", + PlaceholderValues.of(1.5)), expected); + } + + @Test + void boolPlaceholderAsLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(true), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [true, false]", + PlaceholderValues.of(true)), expected); + } + + @Test + void listPlaceholderAsLeftOperand() { + List> outerList = List.of( + List.of(1, 2, 3), List.of(4, 5, 6)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(List.of(1, 2, 3)), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [[1,2,3], [4,5,6]]", + PlaceholderValues.of(List.of(1, 2, 3))), expected); + } + + @Test + void mapPlaceholderAsLeftOperand() { + TreeMap map = new TreeMap<>(); + map.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(map), Exp.val(List.of(map, map2))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in [{1: \"a\"}, {2: \"b\"}]", + PlaceholderValues.of(map)), expected); + } + + @Test + void placeholderAsRightOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(List.of("Bob", "Mary"))), expected); + } + + @Test + void intListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(1, 2, 3))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.age in ?0", + PlaceholderValues.of(List.of(1, 2, 3))), expected); + } + + @Test + void floatListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("score"), Exp.val(List.of(1.5, 2.5))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.score in ?0", + PlaceholderValues.of(List.of(1.5, 2.5))), expected); + } + + @Test + void boolListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("isActive"), Exp.val(List.of(true, false))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.isActive in ?0", + PlaceholderValues.of(List.of(true, false))), expected); + } + + @Test + void listOfListsPlaceholderAsRight() { + List> outerList = List.of( + List.of(1, 2, 3), List.of(4, 5, 6)); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("listBin"), Exp.val(outerList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.listBin in ?0", + PlaceholderValues.of(outerList)), expected); + } + + @Test + void mapListPlaceholderAsRight() { + TreeMap map1 = new TreeMap<>(); + map1.put(1, "a"); + TreeMap map2 = new TreeMap<>(); + map2.put(2, "b"); + List> mapList = List.of(map1, map2); + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("mapBin"), Exp.val(mapList)); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.mapBin in ?0", + PlaceholderValues.of(mapList)), expected); + } + + @Test + void emptyListPlaceholderAsRight() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(Collections.emptyList())); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.intBin1 in ?0", + PlaceholderValues.of(Collections.emptyList())), expected); + } + + @Test + void bothPlaceholders() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in ?1", + PlaceholderValues.of("gold", List.of("gold", "silver"))), expected); + } +} diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java index 1e05f0b..f61baf7 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java @@ -315,4 +315,11 @@ void binLogical_EXCL_no_indexes() { PlaceholderValues.of("stand", "done")), filter, exp); } + @Test + void bothPlaceholdersEquality() { + Exp exp = Exp.eq(Exp.val(42), Exp.val(42)); + TestUtils.parseDslExpressionAndCompare(ExpressionContext.of("?0 == ?1", + PlaceholderValues.of(42, 42)), null, exp); + } + } diff --git a/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java b/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java new file mode 100644 index 0000000..355d452 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java @@ -0,0 +1,31 @@ +package com.aerospike.dsl.parts.operand; + +import com.aerospike.dsl.DslParseException; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OperandFactoryTests { + + @Test + void negMapWithIncomparableKeys() { + Map mixedKeyMap = new HashMap<>(); + mixedKeyMap.put(1, "a"); + mixedKeyMap.put("b", 2); + assertThatThrownBy(() -> OperandFactory.createOperand(mixedKeyMap)) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("mutually comparable"); + } + + @Test + void negMapWithNullKey() { + Map nullKeyMap = new HashMap<>(); + nullKeyMap.put(null, "value"); + assertThatThrownBy(() -> OperandFactory.createOperand(nullKeyMap)) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("mutually comparable"); + } +} From e524295edcf34adfeccdd25b03814e436a70f21d Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 27 Feb 2026 23:44:40 +0100 Subject: [PATCH 06/14] Handle IN token, add tests --- .../parts/cdt/map/MapIndexRangeRelative.java | 10 +- .../aerospike/dsl/parts/cdt/map/MapKey.java | 15 +- .../dsl/parts/cdt/map/MapKeyList.java | 16 +-- .../dsl/parts/cdt/map/MapKeyRange.java | 12 +- .../com/aerospike/dsl/util/ParsingUtils.java | 20 +++ .../expression/InGrammarConflictTests.java | 133 ++++++++++++++++++ 6 files changed, 167 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapIndexRangeRelative.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapIndexRangeRelative.java index 9a3fc19..c2fe881 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapIndexRangeRelative.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapIndexRangeRelative.java @@ -8,9 +8,10 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; +import com.aerospike.dsl.util.ParsingUtils; + import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; import static com.aerospike.dsl.util.ParsingUtils.subtractNullable; -import static com.aerospike.dsl.util.ParsingUtils.unquote; public class MapIndexRangeRelative extends MapPart { private final boolean isInverted; @@ -44,12 +45,7 @@ public static MapIndexRangeRelative from(ConditionParser.MapIndexRangeRelativeCo String relativeKey = null; if (range.relativeKeyEnd().mapKey() != null) { - ConditionParser.MapKeyContext mapKeyContext = range.relativeKeyEnd().mapKey(); - if (mapKeyContext.NAME_IDENTIFIER() != null) { - relativeKey = mapKeyContext.NAME_IDENTIFIER().getText(); - } else if (mapKeyContext.QUOTED_STRING() != null) { - relativeKey = unquote(mapKeyContext.QUOTED_STRING().getText()); - } + relativeKey = ParsingUtils.parseMapKey(range.relativeKeyEnd().mapKey()); } return new MapIndexRangeRelative(isInverted, start, end, relativeKey); } diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java index c4c49a0..2963c2e 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKey.java @@ -1,14 +1,12 @@ package com.aerospike.dsl.parts.cdt.map; import com.aerospike.dsl.ConditionParser; -import com.aerospike.dsl.DslParseException; import com.aerospike.dsl.client.Value; import com.aerospike.dsl.client.cdt.CTX; import com.aerospike.dsl.client.exp.Exp; import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; - -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import com.aerospike.dsl.util.ParsingUtils; public class MapKey extends MapPart { private final String key; @@ -19,16 +17,7 @@ public MapKey(String key) { } public static MapKey from(ConditionParser.MapKeyContext ctx) { - if (ctx.QUOTED_STRING() != null) { - return new MapKey(unquote(ctx.QUOTED_STRING().getText())); - } - if (ctx.NAME_IDENTIFIER() != null) { - return new MapKey(ctx.NAME_IDENTIFIER().getText()); - } - if (ctx.IN() != null) { - return new MapKey(ctx.IN().getText()); - } - throw new DslParseException("Could not translate MapKey from ctx: %s".formatted(ctx)); + return new MapKey(ParsingUtils.parseMapKey(ctx)); } @Override diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyList.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyList.java index 8e32b4c..7ad26e3 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyList.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyList.java @@ -8,9 +8,9 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; -import java.util.List; +import com.aerospike.dsl.util.ParsingUtils; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import java.util.List; public class MapKeyList extends MapPart { private final boolean isInverted; @@ -31,15 +31,9 @@ public static MapKeyList from(ConditionParser.MapKeyListContext ctx) { keyList != null ? keyList.keyListIdentifier() : invertedKeyList.keyListIdentifier(); boolean isInverted = keyList == null; - List keyListStrings = list.mapKey().stream().map( - mapKey -> { - if (mapKey.NAME_IDENTIFIER() != null) { - return mapKey.NAME_IDENTIFIER().getText(); - } else { - return unquote(mapKey.QUOTED_STRING().getText()); - } - } - ).toList(); + List keyListStrings = list.mapKey().stream() + .map(ParsingUtils::parseMapKey) + .toList(); return new MapKeyList(isInverted, keyListStrings); } diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyRange.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyRange.java index 0e45626..b56c719 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyRange.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapKeyRange.java @@ -8,9 +8,9 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; -import java.util.Optional; +import com.aerospike.dsl.util.ParsingUtils; -import static com.aerospike.dsl.util.ParsingUtils.unquote; +import java.util.Optional; public class MapKeyRange extends MapPart { private final boolean isInverted; @@ -33,14 +33,10 @@ public static MapKeyRange from(ConditionParser.MapKeyRangeContext ctx) { keyRange != null ? keyRange.keyRangeIdentifier() : invertedKeyRange.keyRangeIdentifier(); boolean isInverted = keyRange == null; - String startKey = range.mapKey(0).NAME_IDENTIFIER() != null - ? range.mapKey(0).NAME_IDENTIFIER().getText() - : unquote(range.mapKey(0).QUOTED_STRING().getText()); + String startKey = ParsingUtils.parseMapKey(range.mapKey(0)); String endKey = Optional.ofNullable(range.mapKey(1)) - .map(keyCtx -> keyCtx.NAME_IDENTIFIER() != null - ? keyCtx.NAME_IDENTIFIER().getText() - : unquote(keyCtx.QUOTED_STRING().getText())) + .map(ParsingUtils::parseMapKey) .orElse(null); return new MapKeyRange(isInverted, startKey, endKey); diff --git a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index 2f067f4..f6730ed 100644 --- a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java @@ -91,6 +91,26 @@ private static BigInteger parseUnsignedIntegerLiteral(String text) { } } + /** + * Extracts the text content from a {@code mapKey} parser rule context. + * Handles NAME_IDENTIFIER, QUOTED_STRING, and IN keyword (as literal text). + * + * @param ctx The mapKey context from the parser + * @return The parsed key string + */ + public static String parseMapKey(ConditionParser.MapKeyContext ctx) { + if (ctx.NAME_IDENTIFIER() != null) { + return ctx.NAME_IDENTIFIER().getText(); + } + if (ctx.QUOTED_STRING() != null) { + return unquote(ctx.QUOTED_STRING().getText()); + } + if (ctx.IN() != null) { + return ctx.IN().getText(); + } + throw new DslParseException("Could not parse mapKey from ctx: %s".formatted(ctx.getText())); + } + /** * Extracts a typed value from a {@code valueIdentifier} parser rule context. * Handles NAME_IDENTIFIER, QUOTED_STRING, IN keyword (as literal text), and signedInt. diff --git a/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java index 88a71e4..342fd98 100644 --- a/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java @@ -102,4 +102,137 @@ void mapValueNamedInUpperCase() { parseFilterExpressionAndCompare(ExpressionContext.of( "$.mapBin.{=IN}.get(type: STRING) == \"hello\""), expected); } + + @Test + void mapKeyRangeStartIn() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("in"), Exp.val("z"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{in-z}"), expected); + } + + @Test + void mapKeyRangeEndIn() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("a"), Exp.val("in"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{a-in}"), expected); + } + + @Test + void mapKeyRangeOpenEndIn() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("in"), null, Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{in-}"), expected); + } + + @Test + void invertedKeyRangeStartIn() { + Exp expected = MapExp.getByKeyRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("in"), Exp.val("z"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!in-z}"), expected); + } + + @Test + void invertedKeyRangeEndIn() { + Exp expected = MapExp.getByKeyRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("a"), Exp.val("in"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!a-in}"), expected); + } + + @Test + void mapKeyListWithIn() { + Exp expected = MapExp.getByKeyList(MapReturnType.VALUE, + Exp.val(List.of("in", "z")), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{in,z}"), expected); + } + + @Test + void mapKeyListOnlyIn() { + Exp expected = MapExp.getByKeyList(MapReturnType.VALUE, + Exp.val(List.of("in")), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{in}"), expected); + } + + @Test + void invertedKeyListWithIn() { + Exp expected = MapExp.getByKeyList( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val(List.of("in", "z")), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!in,z}"), expected); + } + + @Test + void relativeIndexWithKeyIn() { + Exp expected = MapExp.getByKeyRelativeIndexRange(MapReturnType.VALUE, + Exp.val("in"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{0:1~in}"), expected); + } + + @Test + void invertedRelativeIndexKeyIn() { + Exp expected = MapExp.getByKeyRelativeIndexRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("in"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!0:1~in}"), expected); + } + + @Test + void mapKeyRangeStartInUpperCase() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("IN"), Exp.val("z"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN-z}"), expected); + } + + @Test + void mapKeyRangeEndInUpperCase() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("a"), Exp.val("IN"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{a-IN}"), expected); + } + + @Test + void mapKeyRangeOpenEndInUC() { + Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, + Exp.val("IN"), null, Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN-}"), expected); + } + + @Test + void invertedKeyRangeInUpperCase() { + Exp expected = MapExp.getByKeyRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("IN"), Exp.val("z"), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!IN-z}"), expected); + } + + @Test + void mapKeyListWithInUpperCase() { + Exp expected = MapExp.getByKeyList(MapReturnType.VALUE, + Exp.val(List.of("IN", "z")), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN,z}"), expected); + } + + @Test + void invertedKeyListInUpperCase() { + Exp expected = MapExp.getByKeyList( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val(List.of("IN", "z")), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!IN,z}"), expected); + } + + @Test + void relativeIndexKeyInUpperCase() { + Exp expected = MapExp.getByKeyRelativeIndexRange(MapReturnType.VALUE, + Exp.val("IN"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{0:1~IN}"), expected); + } + + @Test + void invertedRelativeKeyInUC() { + Exp expected = MapExp.getByKeyRelativeIndexRange( + MapReturnType.VALUE | MapReturnType.INVERTED, + Exp.val("IN"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!0:1~IN}"), expected); + } } From fa5450071f55f71e4f16efc6894d0fa4354a9ee3 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 2 Mar 2026 12:05:11 +0100 Subject: [PATCH 07/14] Clean up, remove redundant tests --- .../parts/cdt/list/ListRankRangeRelative.java | 15 ++--- .../parts/cdt/map/MapRankRangeRelative.java | 14 ++--- .../com/aerospike/dsl/util/ParsingUtils.java | 47 ++++++++++----- .../expression/InGrammarConflictTests.java | 59 +------------------ 4 files changed, 40 insertions(+), 95 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListRankRangeRelative.java b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListRankRangeRelative.java index c7ca1bc..378f783 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListRankRangeRelative.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListRankRangeRelative.java @@ -8,9 +8,10 @@ import com.aerospike.dsl.client.exp.ListExp; import com.aerospike.dsl.parts.path.BasePath; +import com.aerospike.dsl.util.ParsingUtils; + import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; import static com.aerospike.dsl.util.ParsingUtils.subtractNullable; -import static com.aerospike.dsl.util.ParsingUtils.unquote; public class ListRankRangeRelative extends ListPart { private final boolean isInverted; @@ -43,17 +44,9 @@ public static ListRankRangeRelative from(ConditionParser.ListRankRangeRelativeCo } Object relativeValue = null; - if (range.relativeRankEnd().relativeValue() != null) { - ConditionParser.ValueIdentifierContext valueIdentifierContext - = range.relativeRankEnd().relativeValue().valueIdentifier(); - if (valueIdentifierContext.signedInt() != null) { - relativeValue = parseSignedInt(valueIdentifierContext.signedInt()); - } else if (valueIdentifierContext.NAME_IDENTIFIER() != null) { - relativeValue = valueIdentifierContext.NAME_IDENTIFIER().getText(); - } else if (valueIdentifierContext.QUOTED_STRING() != null) { - relativeValue = unquote(valueIdentifierContext.QUOTED_STRING().getText()); - } + relativeValue = ParsingUtils.parseValueIdentifier( + range.relativeRankEnd().relativeValue().valueIdentifier()); } return new ListRankRangeRelative(isInverted, start, end, relativeValue); diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapRankRangeRelative.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapRankRangeRelative.java index 1f6b870..d2caab6 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapRankRangeRelative.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapRankRangeRelative.java @@ -8,9 +8,10 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; +import com.aerospike.dsl.util.ParsingUtils; + import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; import static com.aerospike.dsl.util.ParsingUtils.subtractNullable; -import static com.aerospike.dsl.util.ParsingUtils.unquote; public class MapRankRangeRelative extends MapPart { private final boolean isInverted; @@ -44,15 +45,8 @@ public static MapRankRangeRelative from(ConditionParser.MapRankRangeRelativeCont Object relativeValue = null; if (range.relativeRankEnd().relativeValue() != null) { - ConditionParser.ValueIdentifierContext valueIdentifierContext - = range.relativeRankEnd().relativeValue().valueIdentifier(); - if (valueIdentifierContext.signedInt() != null) { - relativeValue = parseSignedInt(valueIdentifierContext.signedInt()); - } else if (valueIdentifierContext.NAME_IDENTIFIER() != null) { - relativeValue = valueIdentifierContext.NAME_IDENTIFIER().getText(); - } else if (valueIdentifierContext.QUOTED_STRING() != null) { - relativeValue = unquote(valueIdentifierContext.QUOTED_STRING().getText()); - } + relativeValue = ParsingUtils.parseValueIdentifier( + range.relativeRankEnd().relativeValue().valueIdentifier()); } return new MapRankRangeRelative(isInverted, start, end, relativeValue); diff --git a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index f6730ed..1f5ae66 100644 --- a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java @@ -4,6 +4,8 @@ import com.aerospike.dsl.DslParseException; import lombok.NonNull; import lombok.experimental.UtilityClass; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.tree.TerminalNode; import java.math.BigInteger; @@ -91,6 +93,29 @@ private static BigInteger parseUnsignedIntegerLiteral(String text) { } } + /** + * Resolves the string content from a parser rule context that may contain + * NAME_IDENTIFIER, QUOTED_STRING, or IN tokens. + * + * @param ctx Any parser rule context containing string-like tokens + * @return The resolved string, or {@code null} if no matching token is found + */ + private static String resolveStringToken(ParserRuleContext ctx) { + TerminalNode nameId = ctx.getToken(ConditionParser.NAME_IDENTIFIER, 0); + if (nameId != null) { + return nameId.getText(); + } + TerminalNode quoted = ctx.getToken(ConditionParser.QUOTED_STRING, 0); + if (quoted != null) { + return unquote(quoted.getText()); + } + TerminalNode in = ctx.getToken(ConditionParser.IN, 0); + if (in != null) { + return in.getText(); + } + return null; + } + /** * Extracts the text content from a {@code mapKey} parser rule context. * Handles NAME_IDENTIFIER, QUOTED_STRING, and IN keyword (as literal text). @@ -99,14 +124,9 @@ private static BigInteger parseUnsignedIntegerLiteral(String text) { * @return The parsed key string */ public static String parseMapKey(ConditionParser.MapKeyContext ctx) { - if (ctx.NAME_IDENTIFIER() != null) { - return ctx.NAME_IDENTIFIER().getText(); - } - if (ctx.QUOTED_STRING() != null) { - return unquote(ctx.QUOTED_STRING().getText()); - } - if (ctx.IN() != null) { - return ctx.IN().getText(); + String result = resolveStringToken(ctx); + if (result != null) { + return result; } throw new DslParseException("Could not parse mapKey from ctx: %s".formatted(ctx.getText())); } @@ -119,14 +139,9 @@ public static String parseMapKey(ConditionParser.MapKeyContext ctx) { * @return The parsed value as String or Integer */ public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { - if (ctx.NAME_IDENTIFIER() != null) { - return ctx.NAME_IDENTIFIER().getText(); - } - if (ctx.QUOTED_STRING() != null) { - return unquote(ctx.QUOTED_STRING().getText()); - } - if (ctx.IN() != null) { - return ctx.IN().getText(); + String result = resolveStringToken(ctx); + if (result != null) { + return result; } if (ctx.signedInt() != null) { return parseSignedInt(ctx.signedInt()); diff --git a/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java index 342fd98..745845e 100644 --- a/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java @@ -23,6 +23,7 @@ void caseInsensitiveIn() { parseFilterExpressionAndCompare(ExpressionContext.of("$.name IN [\"Bob\"]"), expected); parseFilterExpressionAndCompare(ExpressionContext.of("$.name In [\"Bob\"]"), expected); parseFilterExpressionAndCompare(ExpressionContext.of("$.name iN [\"Bob\"]"), expected); + parseFilterExpressionAndCompare(ExpressionContext.of("$.name in [\"Bob\"]"), expected); } @Test @@ -177,62 +178,4 @@ void invertedRelativeIndexKeyIn() { parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!0:1~in}"), expected); } - @Test - void mapKeyRangeStartInUpperCase() { - Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, - Exp.val("IN"), Exp.val("z"), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN-z}"), expected); - } - - @Test - void mapKeyRangeEndInUpperCase() { - Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, - Exp.val("a"), Exp.val("IN"), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{a-IN}"), expected); - } - - @Test - void mapKeyRangeOpenEndInUC() { - Exp expected = MapExp.getByKeyRange(MapReturnType.VALUE, - Exp.val("IN"), null, Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN-}"), expected); - } - - @Test - void invertedKeyRangeInUpperCase() { - Exp expected = MapExp.getByKeyRange( - MapReturnType.VALUE | MapReturnType.INVERTED, - Exp.val("IN"), Exp.val("z"), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!IN-z}"), expected); - } - - @Test - void mapKeyListWithInUpperCase() { - Exp expected = MapExp.getByKeyList(MapReturnType.VALUE, - Exp.val(List.of("IN", "z")), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{IN,z}"), expected); - } - - @Test - void invertedKeyListInUpperCase() { - Exp expected = MapExp.getByKeyList( - MapReturnType.VALUE | MapReturnType.INVERTED, - Exp.val(List.of("IN", "z")), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!IN,z}"), expected); - } - - @Test - void relativeIndexKeyInUpperCase() { - Exp expected = MapExp.getByKeyRelativeIndexRange(MapReturnType.VALUE, - Exp.val("IN"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{0:1~IN}"), expected); - } - - @Test - void invertedRelativeKeyInUC() { - Exp expected = MapExp.getByKeyRelativeIndexRange( - MapReturnType.VALUE | MapReturnType.INVERTED, - Exp.val("IN"), Exp.val(0), Exp.val(1), Exp.mapBin("mapBin")); - parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.{!0:1~IN}"), expected); - } } From 7701012dcca27bcb70e7cf2e276abb172023feb5 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 2 Mar 2026 13:34:22 +0100 Subject: [PATCH 08/14] Code review fixes: Javadoc, type safety, NPE guard Made-with: Cursor --- .../dsl/parts/cdt/list/ListValueRange.java | 9 ++--- .../dsl/parts/cdt/map/MapValueRange.java | 8 ++-- .../dsl/parts/operand/OperandFactory.java | 19 +++++---- .../com/aerospike/dsl/util/ParsingUtils.java | 17 ++++++++ .../visitor/ExpressionConditionVisitor.java | 19 +++++++++ .../aerospike/dsl/visitor/VisitorUtils.java | 40 ++++++++++++++++--- 6 files changed, 91 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueRange.java b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueRange.java index a1057ce..a9b9bee 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueRange.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/list/ListValueRange.java @@ -8,7 +8,7 @@ import com.aerospike.dsl.client.exp.ListExp; import com.aerospike.dsl.parts.path.BasePath; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; +import static com.aerospike.dsl.util.ParsingUtils.requireIntValueIdentifier; public class ListValueRange extends ListPart { private final boolean isInverted; @@ -31,12 +31,11 @@ public static ListValueRange from(ConditionParser.ListValueRangeContext ctx) { valueRange != null ? valueRange.valueRangeIdentifier() : invertedValueRange.valueRangeIdentifier(); boolean isInverted = valueRange == null; - Integer startValue = parseSignedInt(range.valueIdentifier(0).signedInt()); + Integer startValue = requireIntValueIdentifier(range.valueIdentifier(0)); Integer endValue = null; - - if (range.valueIdentifier(1) != null && range.valueIdentifier(1).signedInt() != null) { - endValue = parseSignedInt(range.valueIdentifier(1).signedInt()); + if (range.valueIdentifier(1) != null) { + endValue = requireIntValueIdentifier(range.valueIdentifier(1)); } return new ListValueRange(isInverted, startValue, endValue); diff --git a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueRange.java b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueRange.java index 7eb505d..efe433f 100644 --- a/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueRange.java +++ b/src/main/java/com/aerospike/dsl/parts/cdt/map/MapValueRange.java @@ -8,7 +8,7 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.parts.path.BasePath; -import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; +import static com.aerospike.dsl.util.ParsingUtils.requireIntValueIdentifier; public class MapValueRange extends MapPart { private final boolean isInverted; @@ -31,11 +31,11 @@ public static MapValueRange from(ConditionParser.MapValueRangeContext ctx) { valueRange != null ? valueRange.valueRangeIdentifier() : invertedValueRange.valueRangeIdentifier(); boolean isInverted = valueRange == null; - Integer startValue = parseSignedInt(range.valueIdentifier(0).signedInt()); + Integer startValue = requireIntValueIdentifier(range.valueIdentifier(0)); Integer endValue = null; - if (range.valueIdentifier(1) != null && range.valueIdentifier(1).signedInt() != null) { - endValue = parseSignedInt(range.valueIdentifier(1).signedInt()); + if (range.valueIdentifier(1) != null) { + endValue = requireIntValueIdentifier(range.valueIdentifier(1)); } return new MapValueRange(isInverted, startValue, endValue); diff --git a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java index 0ef686b..cedea87 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -41,7 +41,6 @@ public interface OperandFactory { * @throws IllegalArgumentException If the value provided is {@code null}. * @throws UnsupportedOperationException If the type of the value is not supported by the factory. */ - @SuppressWarnings("unchecked") static AbstractPart createOperand(Object value) { if (value == null) { throw new IllegalArgumentException("Cannot create operand from null value"); @@ -55,13 +54,19 @@ static AbstractPart createOperand(Object value) { return new FloatOperand(((Number) value).doubleValue()); } else if (value instanceof Integer || value instanceof Long) { return new IntOperand(((Number) value).longValue()); - } else if (value instanceof List) { - return new ListOperand((List) value); - } else if (value instanceof SortedMap) { - return new MapOperand((SortedMap) value); - } else if (value instanceof Map) { + } else if (value instanceof List list) { + @SuppressWarnings("unchecked") + List objectList = (List) list; + return new ListOperand(objectList); + } else if (value instanceof SortedMap sortedMap) { + @SuppressWarnings("unchecked") + SortedMap objectMap = (SortedMap) sortedMap; + return new MapOperand(objectMap); + } else if (value instanceof Map map) { try { - return new MapOperand(new TreeMap<>((Map) value)); + @SuppressWarnings("unchecked") + Map objectMap = (Map) map; + return new MapOperand(new TreeMap<>(objectMap)); } catch (ClassCastException | NullPointerException e) { throw new DslParseException( "Map keys must be mutually comparable for operand creation", e); diff --git a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index 1f5ae66..ef75653 100644 --- a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java @@ -149,6 +149,23 @@ public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext throw new DslParseException("Could not parse valueIdentifier from ctx: %s".formatted(ctx.getText())); } + /** + * Parses a {@code valueIdentifier} context and requires the result to be an {@link Integer}. + * Used by value-range elements where only integer operands are valid. + * + * @param ctx The valueIdentifier context from the parser + * @return The parsed integer value + * @throws DslParseException if the parsed value is not an integer + */ + public static Integer requireIntValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { + Object result = parseValueIdentifier(ctx); + if (result instanceof Integer intValue) { + return intValue; + } + throw new DslParseException( + "Value range requires integer operands, got: %s".formatted(ctx.getText())); + } + /** * Get the string inside the quotes. * diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 177a564..64cf1e8 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -271,6 +271,14 @@ public AbstractPart visitInExpression(ConditionParser.InExpressionContext ctx) { return new ExpressionContainer(left, right, ExpressionContainer.ExprPartsOperation.IN); } + /** + * Validates that both operands of an IN expression are non-null and that + * the right operand is a list-compatible type (LIST_OPERAND, BIN_PART, + * PATH_OPERAND, VARIABLE_OPERAND, or PLACEHOLDER_OPERAND). + * + * @throws DslParseException if either operand is null or the right operand + * is not a list-compatible type + */ private static void validateInRightOperand(AbstractPart left, AbstractPart right) { if (left == null) { throw new DslParseException("Unable to parse left operand"); @@ -288,6 +296,17 @@ private static void validateInRightOperand(AbstractPart left, AbstractPart right throw new DslParseException("IN operation requires a List as the right operand"); } + /** + * Infers and validates Exp types for IN expression operands. + *

    + * For the right operand: if it is a BIN_PART without an explicit type, its + * type is set to LIST; if it has an explicit non-LIST type, an error is thrown. + * If it is a PATH_OPERAND with a path function whose type is non-LIST, an error + * is thrown. + *

    + * For the left operand: delegates to {@link VisitorUtils#inferLeftBinTypeFromList} + * to infer the bin type from the list elements when the right operand is a list literal. + */ private static void inferInTypes(AbstractPart left, AbstractPart right) { if (right.getPartType() == AbstractPart.PartType.BIN_PART) { BinPart rightBin = (BinPart) right; diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index 2554343..5f614a2 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -1123,12 +1123,17 @@ private static void replacePlaceholdersInExclusiveStructure(AbstractPart part, P /** * Replaces placeholders within an {@link ExpressionContainer}. *

    - * This method checks the left and right operands of the {@link ExpressionContainer}. If either - * operand is a {@link PlaceholderOperand}, it resolves the placeholder using the provided - * {@link PlaceholderValues} and updates the operand. For specific comparison operations - * (LT, LTEQ, GT, GTEQ, NOTEQ, EQ), it also calls {@code overrideTypeInfo} after - * resolution. + * Both the left and right operands are checked independently, so both may be + * placeholders and both will be resolved in a single pass (e.g. {@code ?0 == ?1}). *

    + *

    + * After resolution, operation-specific type inference is applied: + *

      + *
    • For comparison operations (LT, LTEQ, GT, GTEQ, NOTEQ, EQ) — + * {@code overrideTypeInfo} reconciles operand types.
    • + *
    • For IN operations — the right placeholder is validated to be a {@link java.util.List} + * before resolution, then {@code inferLeftBinTypeFromList} infers the left bin type.
    • + *
    * * @param part The {@link AbstractPart} representing the {@link ExpressionContainer} * @param placeholderValues An object storing placeholder indexes and their resolved values @@ -1162,6 +1167,13 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh } } + /** + * Validates that a placeholder used as the right operand of an IN expression + * resolves to a {@link java.util.List}. Called before placeholder resolution + * so that the error message references the placeholder index. + * + * @throws DslParseException if the placeholder value is not a List + */ private static void validateInPlaceholderValue(PlaceholderOperand placeholder, PlaceholderValues placeholderValues) { Object value = placeholderValues.getValue(placeholder.getIndex()); @@ -1170,6 +1182,17 @@ private static void validateInPlaceholderValue(PlaceholderOperand placeholder, } } + /** + * Infers the left bin's Exp type from the elements of the right list operand. + *

    + * Only applies when the left operand is a BIN_PART and the right is a LIST_OPERAND. + * If the bin type is not explicitly set, it is updated to the inferred type; + * if it is explicitly set, compatibility is validated via {@code validateComparableTypes}. + * Empty lists are silently skipped (no inference possible). + * + * @param left the left operand of the IN expression + * @param right the right operand of the IN expression + */ static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { if (left.getPartType() != BIN_PART || right.getPartType() != LIST_OPERAND) { @@ -1543,6 +1566,13 @@ && resolveExpType(container.getLeft()) == Exp.Type.FLOAT) { return null; } + /** + * Builds an Exp for an IN expression using {@code ListExp.getByValue(EXISTS, ...)}. + * + * @param left the value to search for + * @param right the list to search in + * @return an Exp that evaluates to true if the left value exists in the right list + */ private static Exp buildInExpression(AbstractPart left, AbstractPart right) { Exp leftExp = processOperand(left); Exp rightExp = processOperand(right); From 2a74f6030daf286016f3582b172cddb23489c951 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 2 Mar 2026 18:56:30 +0100 Subject: [PATCH 09/14] Update list homogeneity validation --- .gitignore | 12 ++++ .../visitor/ExpressionConditionVisitor.java | 8 ++- .../aerospike/dsl/visitor/VisitorUtils.java | 53 +++++++++++------ .../dsl/expression/InNegativeTests.java | 59 +++++++++++++++++-- 4 files changed, 106 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 1be8f55..0a73355 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,17 @@ out/ *.ipr *.iws +## Static Analysis +static-analysis/ + ## OS X .DS_Store + +## Cursor +.cursor/ + +## Local +tdd/ +pmd-ruleset.xml +spotbugs-exclude.xml +pom-analysis.xml diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 64cf1e8..b59f42f 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -304,8 +304,9 @@ private static void validateInRightOperand(AbstractPart left, AbstractPart right * If it is a PATH_OPERAND with a path function whose type is non-LIST, an error * is thrown. *

    - * For the left operand: delegates to {@link VisitorUtils#inferLeftBinTypeFromList} - * to infer the bin type from the list elements when the right operand is a list literal. + * For the left operand: delegates to {@link VisitorUtils#validateListHomogeneity} + * and {@link VisitorUtils#inferBinTypeFromList} to enforce uniform list element types + * and infer the bin type when the right operand is a list literal. */ private static void inferInTypes(AbstractPart left, AbstractPart right) { if (right.getPartType() == AbstractPart.PartType.BIN_PART) { @@ -326,7 +327,8 @@ private static void inferInTypes(AbstractPart left, AbstractPart right) { } } } - VisitorUtils.inferLeftBinTypeFromList(left, right); + Exp.Type inferredType = VisitorUtils.validateListHomogeneity(right); + VisitorUtils.inferBinTypeFromList(left, inferredType); } @Override diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index 5f614a2..ae4c52f 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -1132,7 +1132,8 @@ private static void replacePlaceholdersInExclusiveStructure(AbstractPart part, P *

  • For comparison operations (LT, LTEQ, GT, GTEQ, NOTEQ, EQ) — * {@code overrideTypeInfo} reconciles operand types.
  • *
  • For IN operations — the right placeholder is validated to be a {@link java.util.List} - * before resolution, then {@code inferLeftBinTypeFromList} infers the left bin type.
  • + * before resolution, then {@code validateListHomogeneity} enforces uniform element types + * and {@code inferBinTypeFromList} infers the left bin type. * * * @param part The {@link AbstractPart} representing the {@link ExpressionContainer} @@ -1163,7 +1164,8 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh overrideTypeInfo(expr.getLeft(), expr.getRight()); } if (isResolved && expr.getOperationType() == IN) { - inferLeftBinTypeFromList(expr.getLeft(), expr.getRight()); + Exp.Type inferredType = validateListHomogeneity(expr.getRight()); + inferBinTypeFromList(expr.getLeft(), inferredType); } } @@ -1176,33 +1178,47 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh */ private static void validateInPlaceholderValue(PlaceholderOperand placeholder, PlaceholderValues placeholderValues) { - Object value = placeholderValues.getValue(placeholder.getIndex()); + int index = placeholder.getIndex(); + Object value = placeholderValues.getValue(index); if (!(value instanceof List)) { - throw new DslParseException("IN operation requires a List as the right operand"); + throw new DslParseException( + "IN operation requires a List as the right operand for placeholder ?" + index); } } /** - * Infers the left bin's Exp type from the elements of the right list operand. + * Validates that all elements in a LIST_OPERAND are of the same type. *

    - * Only applies when the left operand is a BIN_PART and the right is a LIST_OPERAND. - * If the bin type is not explicitly set, it is updated to the inferred type; - * if it is explicitly set, compatibility is validated via {@code validateComparableTypes}. - * Empty lists are silently skipped (no inference possible). + * If the right operand is not a LIST_OPERAND, returns {@code null} (nothing to validate). + * Empty or all-null lists also return {@code null} (no type can be inferred). * - * @param left the left operand of the IN expression * @param right the right operand of the IN expression + * @return the inferred element type, or {@code null} when validation is not applicable + * @throws DslParseException if the list contains elements of different types */ - static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { - if (left.getPartType() != BIN_PART - || right.getPartType() != LIST_OPERAND) { - return; + static Exp.Type validateListHomogeneity(AbstractPart right) { + if (right.getPartType() != LIST_OPERAND) { + return null; } - BinPart leftBin = (BinPart) left; - Exp.Type inferredType = inferTypeFromListElements((ListOperand) right); - if (inferredType == null) { + return inferTypeFromListElements((ListOperand) right); + } + + /** + * Infers or validates the left BIN_PART's Exp type against the list element type. + *

    + * If the left operand is not a BIN_PART or {@code inferredType} is {@code null}, + * this method is a no-op. + * When the bin type is not explicitly set, it is updated to {@code inferredType}; + * when it is explicitly set, compatibility is validated via {@code validateComparableTypes}. + * + * @param left the left operand of the IN expression + * @param inferredType the element type inferred from the right list operand (may be {@code null}) + */ + static void inferBinTypeFromList(AbstractPart left, Exp.Type inferredType) { + if (inferredType == null || left.getPartType() != BIN_PART) { return; } + BinPart leftBin = (BinPart) left; if (!leftBin.isTypeExplicitlySet()) { leftBin.updateExp(inferredType); } else { @@ -1217,7 +1233,8 @@ static void inferLeftBinTypeFromList(AbstractPart left, AbstractPart right) { * logical type. If heterogeneous element types are detected, a * {@link DslParseException} is thrown to avoid silent type mismatches. * - * @return the inferred type, or {@code null} if the list is empty + * @return the inferred type, or {@code null} if the list is empty or contains only nulls + * (no type can be inferred, so homogeneity validation is intentionally skipped) */ static Exp.Type inferTypeFromListElements(ListOperand listOperand) { List values = listOperand.getValue(); diff --git a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java index edca547..c9aebf3 100644 --- a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -5,6 +5,7 @@ import com.aerospike.dsl.PlaceholderValues; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import static com.aerospike.dsl.util.TestUtils.parseFilterExp; @@ -92,7 +93,8 @@ void negPlaceholderResolvesToStr() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of("Bob")))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); } @Test @@ -100,7 +102,8 @@ void negPlaceholderResolvesToInt() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); } @Test @@ -108,7 +111,8 @@ void negPlaceholderResolvesToFloat() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); } @Test @@ -116,7 +120,8 @@ void negPlaceholderResolvesToBool() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); } @Test @@ -125,6 +130,50 @@ void negPlaceholderResolvesToMap() { ExpressionContext.of("$.name in ?0", PlaceholderValues.of(Map.of("a", 1))))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand"); + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); + } + + @Test + void negLiteralInMixedTypeList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("\"x\" in [1, \"y\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negPlaceholderInMixedTypeList() { + // Placeholder value is irrelevant: homogeneity validation fires at parse time before resolution + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("?0 in [1, \"y\"]", + PlaceholderValues.of(42)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negPathInMixedTypeList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.rooms.room1.name in [1, \"y\"]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negMixedTypeListViaPlaceholder() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(List.of(1, "hello"))))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + @Test + void negVariableInMixedTypeList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = 1) do (${x} in [1, \"y\"])"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); } } From 2216412a55085278cb65893a9ba638c9bbbbf8fd Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 2 Mar 2026 18:57:16 +0100 Subject: [PATCH 10/14] Add light .editorconfig --- .editorconfig | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..00bfcba --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +end_of_line = lf + +[*.java] +indent_style = space +indent_size = 4 +insert_final_newline = true +max_line_length = 120 +ij_java_wrap_long_lines = true +ij_java_wrap_comments = true +ij_java_method_call_chain_wrap = normal +ij_java_blank_lines_after_class_header = 1 +ij_java_class_count_to_use_import_on_demand = 10 +ij_java_names_count_to_use_import_on_demand = 10 \ No newline at end of file From db3f33d1f84fea034c685c617cc24f186dd77716 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 2 Mar 2026 22:19:23 +0100 Subject: [PATCH 11/14] Update exception usage, add tests --- .../com/aerospike/dsl/visitor/VisitorUtils.java | 9 +++++++-- .../dsl/expression/InNegativeTests.java | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index ae4c52f..237e507 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -1174,12 +1174,17 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh * resolves to a {@link java.util.List}. Called before placeholder resolution * so that the error message references the placeholder index. * - * @throws DslParseException if the placeholder value is not a List + * @throws DslParseException if the placeholder index is missing or the placeholder value is not a List */ private static void validateInPlaceholderValue(PlaceholderOperand placeholder, PlaceholderValues placeholderValues) { int index = placeholder.getIndex(); - Object value = placeholderValues.getValue(index); + Object value; + try { + value = placeholderValues.getValue(index); + } catch (IllegalArgumentException e) { + throw new DslParseException(e.getMessage(), e); + } if (!(value instanceof List)) { throw new DslParseException( "IN operation requires a List as the right operand for placeholder ?" + index); diff --git a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java index c9aebf3..ddd1a58 100644 --- a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -176,4 +176,20 @@ void negVariableInMixedTypeList() { .isInstanceOf(DslParseException.class) .hasMessageContaining("IN list elements must all be of the same type"); } + + @Test + void negPlaceholderMissingValue() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of()))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Missing value for placeholder ?0"); + } + + @Test + void negPlaceholderIndexOutOfBounds() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?1", PlaceholderValues.of(42)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Missing value for placeholder ?1"); + } } From c91fd7c42ca2524d9d3785aa0f542d3d4b348eda Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 9 Mar 2026 17:54:17 +0100 Subject: [PATCH 12/14] Add left operand validation against ambiguity for IN operation --- .../antlr4/com/aerospike/dsl/Condition.g4 | 2 +- .../visitor/ExpressionConditionVisitor.java | 74 ++++- .../aerospike/dsl/expression/InBinTests.java | 4 +- .../dsl/expression/InCompositeTests.java | 2 +- .../dsl/expression/InExplicitTypeTests.java | 298 +++++++++++++++++- .../dsl/expression/InLiteralTests.java | 8 +- .../dsl/expression/InNegativeTests.java | 109 ++++++- .../dsl/expression/InPlaceholderTests.java | 14 +- 8 files changed, 481 insertions(+), 30 deletions(-) diff --git a/src/main/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index da469b0..f8ae60b 100644 --- a/src/main/antlr4/com/aerospike/dsl/Condition.g4 +++ b/src/main/antlr4/com/aerospike/dsl/Condition.g4 @@ -131,7 +131,7 @@ stringOperand: QUOTED_STRING; QUOTED_STRING: ('\'' (~'\'')* '\'') | ('"' (~'"')* '"'); // LIST_TYPE_DESIGNATOR is needed here because the lexer tokenizes '[]' as a single token, -// preventing the parser from matching it as '[' ']' for empty list literals (e.g. in "$.bin in []"). +// preventing the parser from matching it as '[' ']' for empty list literals. listConstant: '[' unaryExpression? (',' unaryExpression)* ']' | LIST_TYPE_DESIGNATOR; orderedMapConstant: '{' mapPairConstant? (',' mapPairConstant)* '}'; diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index b59f42f..ea94e0b 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -304,7 +304,8 @@ private static void validateInRightOperand(AbstractPart left, AbstractPart right * If it is a PATH_OPERAND with a path function whose type is non-LIST, an error * is thrown. *

    - * For the left operand: delegates to {@link VisitorUtils#validateListHomogeneity} + * For the left operand: validates that the type is not ambiguous, then delegates + * to {@link VisitorUtils#validateListHomogeneity} * and {@link VisitorUtils#inferBinTypeFromList} to enforce uniform list element types * and infer the bin type when the right operand is a list literal. */ @@ -328,9 +329,80 @@ private static void inferInTypes(AbstractPart left, AbstractPart right) { } } Exp.Type inferredType = VisitorUtils.validateListHomogeneity(right); + validateLeftTypeNotAmbiguous(left, inferredType); VisitorUtils.inferBinTypeFromList(left, inferredType); } + /** + * Validates that the left operand of an IN expression has a deterministic type. + *

    + * When the right operand is a typed list literal, the left type can be inferred + * from the list elements ({@code inferredType != null}). Otherwise, BIN_PART + * and PATH_OPERAND operands must carry an explicit type annotation + * (e.g. {@code .get(type: INT)}, {@code .asInt()}, {@code []}, {@code {}}). + * + * @param left the left operand of the IN expression + * @param inferredType the element type inferred from the right list (may be {@code null}) + * @throws DslParseException if the left operand type cannot be determined + */ + private static void validateLeftTypeNotAmbiguous(AbstractPart left, Exp.Type inferredType) { + if (inferredType != null) { + return; + } + if (!isLeftTypeAmbiguous(left)) { + return; + } + throw new DslParseException( + "cannot infer the type of the left operand for IN operation; " + + "use .get(type: ) to specify it"); + } + + /** + * Checks whether a left operand of IN has ambiguous type. + *

    + * Only {@code BIN_PART} and {@code PATH_OPERAND} can be ambiguous. + * Literals, expressions, placeholders, and variables always carry + * concrete types and are never ambiguous. + */ + private static boolean isLeftTypeAmbiguous(AbstractPart left) { + if (left.getPartType() == AbstractPart.PartType.BIN_PART) { + return !((BinPart) left).isTypeExplicitlySet(); + } + if (left.getPartType() == AbstractPart.PartType.PATH_OPERAND) { + return isPathTypeAmbiguous((Path) left); + } + return false; + } + + /** + * A PATH_OPERAND is ambiguous unless it has: + *

      + *
    • a path function with an explicit binType (e.g. {@code .get(type: X)}), or
    • + *
    • a COUNT or SIZE path function (returns INT), or
    • + *
    • a type designator as the last CDT part ({@code []} or {@code {}}).
    • + *
    + */ + private static boolean isPathTypeAmbiguous(Path path) { + PathFunction pathFunc = path.getPathFunction(); + if (pathFunc != null) { + // CAST is also covered here: always constructed with a non-null binType + if (pathFunc.getBinType() != null) { + return false; + } + PathFunction.PathFunctionType pathFuncType = pathFunc.getPathFunctionType(); + if (pathFuncType == PathFunction.PathFunctionType.COUNT + || pathFuncType == PathFunction.PathFunctionType.SIZE) { + return false; + } + } + List cdtParts = path.getBasePath().getCdtParts(); + if (!cdtParts.isEmpty()) { + AbstractPart lastCdt = cdtParts.get(cdtParts.size() - 1); + return !(lastCdt instanceof ListTypeDesignator) && !(lastCdt instanceof MapTypeDesignator); + } + return true; + } + @Override public AbstractPart visitGreaterThanExpression(ConditionParser.GreaterThanExpressionContext ctx) { AbstractPart left = visit(ctx.bitwiseExpression(0)); diff --git a/src/test/java/com/aerospike/dsl/expression/InBinTests.java b/src/test/java/com/aerospike/dsl/expression/InBinTests.java index ed60246..4d14328 100644 --- a/src/test/java/com/aerospike/dsl/expression/InBinTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InBinTests.java @@ -71,7 +71,7 @@ void binInBin() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.intBin("itemType"), Exp.listBin("allowedItems")); parseFilterExpressionAndCompare( - ExpressionContext.of("$.itemType in $.allowedItems"), expected); + ExpressionContext.of("$.itemType.get(type: INT) in $.allowedItems"), expected); } @Test @@ -85,6 +85,6 @@ void binInNestedPath() { Exp.mapBin("rooms"), CTX.mapKey(Value.get("config")))); parseFilterExpressionAndCompare(ExpressionContext.of( - "$.intBin in $.rooms.config.allowedNames"), expected); + "$.intBin.get(type: INT) in $.rooms.config.allowedNames"), expected); } } diff --git a/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java index 84c223a..e76d8f8 100644 --- a/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java @@ -41,7 +41,7 @@ void complexExpressionWithIn() { ListExp.getByValue(ListReturnType.EXISTS, Exp.val("available"), Exp.listBin("bookableStates"))); parseFilterExpressionAndCompare( - ExpressionContext.of("$.cost > 50 and $.status in $.allowedStatuses" + + ExpressionContext.of("$.cost > 50 and $.status.get(type: INT) in $.allowedStatuses" + " and \"available\" in $.bookableStates"), expected); } diff --git a/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java index 64ae86e..17196c0 100644 --- a/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java @@ -2,9 +2,12 @@ import com.aerospike.dsl.DslParseException; import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.PlaceholderValues; import com.aerospike.dsl.client.cdt.ListReturnType; +import com.aerospike.dsl.client.cdt.MapReturnType; import com.aerospike.dsl.client.exp.Exp; import com.aerospike.dsl.client.exp.ListExp; +import com.aerospike.dsl.client.exp.MapExp; import org.junit.jupiter.api.Test; import java.util.List; @@ -20,7 +23,7 @@ void explicitListTypeOnRightBin() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.intBin("intBin1"), Exp.listBin("tags")); parseFilterExpressionAndCompare( - ExpressionContext.of("$.intBin1 in $.tags.get(type: LIST)"), expected); + ExpressionContext.of("$.intBin1.get(type: INT) in $.tags.get(type: LIST)"), expected); } @Test @@ -71,6 +74,299 @@ void explicitBoolInBoolList() { ExpressionContext.of("$.flag.get(type: BOOL) in [true, false]"), expected); } + // --- Explicit type on left BIN_PART, right is a bin --- + + @Test + void explicitStringBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: STRING) in $.list"), expected); + } + + @Test + void explicitIntBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: INT) in $.list"), expected); + } + + @Test + void explicitFloatBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in $.list"), expected); + } + + @Test + void explicitBoolBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("flag"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.flag.get(type: BOOL) in $.list"), expected); + } + + @Test + void explicitListBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("items"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.items.get(type: LIST) in $.list"), expected); + } + + @Test + void explicitMapBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("item"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.item.get(type: MAP) in $.list"), expected); + } + + // --- Explicit type on left BIN_PART, right is a path operand --- + + @Test + void explicitStringBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: STRING) in $.items.tags"), expected); + } + + @Test + void explicitIntBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: INT) in $.items.tags"), expected); + } + + @Test + void explicitFloatBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: FLOAT) in $.items.tags"), expected); + } + + @Test + void explicitBoolBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.boolBin("flag"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.flag.get(type: BOOL) in $.items.tags"), expected); + } + + @Test + void explicitListBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("items"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.items.get(type: LIST) in $.items.tags"), expected); + } + + @Test + void explicitMapBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("item"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.item.get(type: MAP) in $.items.tags"), expected); + } + + // --- Cast on left BIN_PART --- + + @Test + void castIntBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.asInt() in $.list"), expected); + } + + @Test + void castFloatBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.asFloat() in $.list"), expected); + } + + @Test + void castIntBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.asInt() in $.items.tags"), expected); + } + + @Test + void castFloatBinInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.floatBin("val"), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("tags"), Exp.mapBin("items"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.asFloat() in $.items.tags"), expected); + } + + // --- Explicit type on left PATH_OPERAND --- + + @Test + void explicitPathInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("name"), Exp.mapBin("rooms")), + Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.rooms.name.get(type: STRING) in $.list"), expected); + } + + @Test + void explicitPathInPath() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("name"), Exp.mapBin("rooms")), + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("list"), Exp.mapBin("rooms2"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.rooms.name.get(type: STRING) in $.rooms2.list"), expected); + } + + // --- Explicit type on both sides --- + + @Test + void explicitBinInExplicitBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("val"), Exp.listBin("tags")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.val.get(type: INT) in $.tags.get(type: LIST)"), expected); + } + + // --- Explicit type with placeholder right --- + + @Test + void explicitBinInPlaceholder() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: STRING) in ?0", + PlaceholderValues.of(List.of("Bob"))), expected); + } + + @Test + void explicitPathInPlaceholder() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.STRING, + Exp.val("name"), Exp.mapBin("rooms")), + Exp.val(List.of("Bob"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.rooms.name.get(type: STRING) in ?0", + PlaceholderValues.of(List.of("Bob"))), expected); + } + + // --- Explicit type with list-designator right --- + + @Test + void explicitBinInListDesignator() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("name"), Exp.listBin("binName")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: INT) in $.binName.[]"), expected); + } + + // --- Placeholder left (concrete value, not ambiguous) --- + + @Test + void posBothPlaceholders() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("gold"), Exp.val(List.of("gold", "silver"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in ?1", + PlaceholderValues.of("gold", List.of("gold", "silver"))), expected); + } + + @Test + void posPlaceholderInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(42), Exp.listBin("bin")); + parseFilterExpressionAndCompare( + ExpressionContext.of("?0 in $.bin", + PlaceholderValues.of(42)), expected); + } + + // --- Variable left (concrete value, not ambiguous) --- + + @Test + void posVariableInBin() { + Exp expected = Exp.let( + Exp.def("x", Exp.val(1)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.var("x"), Exp.listBin("list"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = 1) do (${x} in $.list)"), expected); + } + + // --- PATH_OPERAND with type designator (not ambiguous) --- + + @Test + void listDesignatorBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.listBin("items"), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.items.[] in $.list"), expected); + } + + @Test + void mapDesignatorBinInBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.mapBin("item"), Exp.listBin("list") + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.item.{} in $.list"), expected); + } + + // --- PATH_OPERAND with COUNT/SIZE function (known INT return) --- + + @Test + void countPathInListBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + ListExp.size( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.LIST, + Exp.val(0), Exp.listBin("items")) + ), + Exp.listBin("list") + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.items.[0].count() in $.list"), expected); + } + + @Test + void countListBin() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + ListExp.size(Exp.listBin("items")), + Exp.listBin("list") + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.items.[].count() in $.list"), expected); + } + + // --- Negative: explicit type on right, non-LIST --- + @Test void negExplicitIntTypeOnRightBin() { assertThatThrownBy(() -> parseFilterExp( diff --git a/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java b/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java index c6f0dac..6a5c9a3 100644 --- a/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InLiteralTests.java @@ -10,7 +10,6 @@ import com.aerospike.dsl.client.exp.MapExp; import org.junit.jupiter.api.Test; -import java.util.Collections; import java.util.List; import java.util.TreeMap; @@ -138,10 +137,11 @@ void nestedPathInListLiteral() { } @Test - void inWithEmptyList() { + void explicitIntBinInListDesignator() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, - Exp.intBin("name"), Exp.val(Collections.emptyList())); - parseFilterExpressionAndCompare(ExpressionContext.of("$.name in []"), expected); + Exp.intBin("name"), Exp.listBin("binName")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: INT) in $.binName.[]"), expected); } @Test diff --git a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java index ddd1a58..0d74601 100644 --- a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -93,8 +93,7 @@ void negPlaceholderResolvesToStr() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of("Bob")))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand") - .hasMessageContaining("placeholder ?0"); + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } @Test @@ -102,8 +101,7 @@ void negPlaceholderResolvesToInt() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand") - .hasMessageContaining("placeholder ?0"); + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } @Test @@ -111,8 +109,7 @@ void negPlaceholderResolvesToFloat() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand") - .hasMessageContaining("placeholder ?0"); + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } @Test @@ -120,8 +117,7 @@ void negPlaceholderResolvesToBool() { assertThatThrownBy(() -> parseFilterExp( ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand") - .hasMessageContaining("placeholder ?0"); + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } @Test @@ -130,8 +126,7 @@ void negPlaceholderResolvesToMap() { ExpressionContext.of("$.name in ?0", PlaceholderValues.of(Map.of("a", 1))))) .isInstanceOf(DslParseException.class) - .hasMessageContaining("IN operation requires a List as the right operand") - .hasMessageContaining("placeholder ?0"); + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } @Test @@ -163,7 +158,7 @@ void negPathInMixedTypeList() { @Test void negMixedTypeListViaPlaceholder() { assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", + ExpressionContext.of("$.name.get(type: STRING) in ?0", PlaceholderValues.of(List.of(1, "hello"))))) .isInstanceOf(DslParseException.class) .hasMessageContaining("IN list elements must all be of the same type"); @@ -177,10 +172,98 @@ void negVariableInMixedTypeList() { .hasMessageContaining("IN list elements must all be of the same type"); } + // --- Ambiguous left operand --- + + @Test + void negBinInBinAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.itemType in $.allowedItems"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negBinInPathAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.rooms.room1.name"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negBinInListDesignatorAmb() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in $.binName.[]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negBinInPlaceholderAmb() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negBinInVariableAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = [\"a\"]) do ($.name in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPathInBinAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.rooms.room1.name in $.list"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPathInPathAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.rooms.room1.a in $.rooms.room2.b"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPathInPlaceholderAmbiguous() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.rooms.room1.name in ?0"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negExplicitBinInNotList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in ?0", + PlaceholderValues.of("Bob")))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?0"); + } + + @Test + void negPlaceholderInNotList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("?0 in ?1", + PlaceholderValues.of("gold", "notAList")))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("placeholder ?1"); + } + + // --- Missing placeholder values --- + @Test void negPlaceholderMissingValue() { assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?0", PlaceholderValues.of()))) + ExpressionContext.of("$.name.get(type: STRING) in ?0", PlaceholderValues.of()))) .isInstanceOf(DslParseException.class) .hasMessageContaining("Missing value for placeholder ?0"); } @@ -188,7 +271,7 @@ void negPlaceholderMissingValue() { @Test void negPlaceholderIndexOutOfBounds() { assertThatThrownBy(() -> parseFilterExp( - ExpressionContext.of("$.name in ?1", PlaceholderValues.of(42)))) + ExpressionContext.of("$.name.get(type: STRING) in ?1", PlaceholderValues.of(42)))) .isInstanceOf(DslParseException.class) .hasMessageContaining("Missing value for placeholder ?1"); } diff --git a/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java b/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java index d9bccd7..024f6c6 100644 --- a/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InPlaceholderTests.java @@ -80,7 +80,7 @@ void placeholderAsRightOperand() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))); parseFilterExpressionAndCompare( - ExpressionContext.of("$.name in ?0", + ExpressionContext.of("$.name.get(type: STRING) in ?0", PlaceholderValues.of(List.of("Bob", "Mary"))), expected); } @@ -89,7 +89,7 @@ void intListPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.intBin("age"), Exp.val(List.of(1, 2, 3))); parseFilterExpressionAndCompare( - ExpressionContext.of("$.age in ?0", + ExpressionContext.of("$.age.get(type: INT) in ?0", PlaceholderValues.of(List.of(1, 2, 3))), expected); } @@ -98,7 +98,7 @@ void floatListPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.floatBin("score"), Exp.val(List.of(1.5, 2.5))); parseFilterExpressionAndCompare( - ExpressionContext.of("$.score in ?0", + ExpressionContext.of("$.score.get(type: FLOAT) in ?0", PlaceholderValues.of(List.of(1.5, 2.5))), expected); } @@ -107,7 +107,7 @@ void boolListPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.boolBin("isActive"), Exp.val(List.of(true, false))); parseFilterExpressionAndCompare( - ExpressionContext.of("$.isActive in ?0", + ExpressionContext.of("$.isActive.get(type: BOOL) in ?0", PlaceholderValues.of(List.of(true, false))), expected); } @@ -118,7 +118,7 @@ void listOfListsPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.listBin("listBin"), Exp.val(outerList)); parseFilterExpressionAndCompare( - ExpressionContext.of("$.listBin in ?0", + ExpressionContext.of("$.listBin.get(type: LIST) in ?0", PlaceholderValues.of(outerList)), expected); } @@ -132,7 +132,7 @@ void mapListPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.mapBin("mapBin"), Exp.val(mapList)); parseFilterExpressionAndCompare( - ExpressionContext.of("$.mapBin in ?0", + ExpressionContext.of("$.mapBin.get(type: MAP) in ?0", PlaceholderValues.of(mapList)), expected); } @@ -141,7 +141,7 @@ void emptyListPlaceholderAsRight() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, Exp.intBin("intBin1"), Exp.val(Collections.emptyList())); parseFilterExpressionAndCompare( - ExpressionContext.of("$.intBin1 in ?0", + ExpressionContext.of("$.intBin1.get(type: INT) in ?0", PlaceholderValues.of(Collections.emptyList())), expected); } From 5a94b9e4bd9c6ada4f3969ab053995ace4462aaa Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 9 Mar 2026 18:33:09 +0100 Subject: [PATCH 13/14] Add tests for IN operation with secondary index hint --- .../dsl/parsedExpression/InFilterTests.java | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java index f8088c4..a2d566b 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java @@ -358,4 +358,510 @@ void twoInsOnlyIndexed() { ExpressionContext.of("$.b1 in [1] and $.b2 in [2] and $.b3 == 100"), filter, exp, IndexContext.of(NAMESPACE, indexes)); } + + // --- Index Name Hint: IN + comparison --- + + @Test + void inAndEq_indexHint_selectsEq() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin2")); + } + + @Test + void inAndEq_indexHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + @Test + void inAndEqAndGt_indexHint_overrides() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin3")); + } + + @Test + void twoInsAndEq_indexHint() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin3", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4] and $.intBin3 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin3")); + } + + @Test + void twoIns_indexHint_noFilter() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4]"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + @Test + void inAndEq_indexHint_unavailable() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "nonExistent")); + } + + @Test + void inAndEq_indexHint_nsMismatch() { + List indexes = List.of( + Index.builder().namespace("other_ns").name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + @Test + void inAndEq_indexHint_null() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, null)); + } + + @Test + void inAndEq_indexHint_empty() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "")); + } + + @Test + void inAndEq_indexHint_overridesAlpha() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(100).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin3")); + } + + // --- Index Name Hint: 3 sub-expressions with hint on IN bin --- + + @Test + void inAndEqAndGt_indexHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.gt(Exp.intBin("intBin3"), Exp.val(50))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + @Test + void twoInsAndLt_indexHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = Filter.range("intBin3", Long.MIN_VALUE, 49); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4] and $.intBin3 < 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + // --- Index Name Hint: OR expressions --- + + @Test + void inOrEq_indexHint_noFilter() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] or $.intBin2 == 100"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin2")); + } + + @Test + void orInAndGt_indexHint_onOrInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).name("idx_intBin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).name("idx_intBin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("($.intBin1 in [1, 2] or $.intBin2 == 100) and $.intBin3 > 50"), + filter, exp, IndexContext.of(NAMESPACE, indexes, "idx_intBin1")); + } + + // --- Bin Name Hint: IN + comparison --- + + @Test + void inAndEq_binHint_selectsEq() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin2")); + } + + @Test + void inAndEq_binHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } + + @Test + void inAndEqAndGt_binHint_overrides() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin3")); + } + + @Test + void twoInsAndEq_binHint() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin3", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4] and $.intBin3 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin3")); + } + + @Test + void twoIns_binHint_noFilter() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4]"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } + + @Test + void inAndEq_binHint_noMatch() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "nonExistent")); + } + + @Test + void inAndEq_binHint_null() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, null)); + } + + @Test + void inAndEq_binHint_nsMismatch() { + List indexes = List.of( + Index.builder().namespace("other_ns").bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } + + @Test + void inAndEq_binHint_overridesAlpha() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(100).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin3")); + } + + // --- Bin Name Hint: 3 sub-expressions with hint on IN bin --- + + @Test + void inAndEqAndGt_binHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.gt(Exp.intBin("intBin3"), Exp.val(50))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 == 100 and $.intBin3 > 50"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } + + @Test + void twoInsAndLt_binHint_onInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = Filter.range("intBin3", Long.MIN_VALUE, 49); + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin2"), Exp.val(List.of(3, 4)))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] and $.intBin2 in [3, 4] and $.intBin3 < 50"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } + + // --- Bin Name Hint: OR expressions --- + + @Test + void inOrEq_binHint_noFilter() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2] or $.intBin2 == 100"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin2")); + } + + @Test + void orInAndGt_binHint_onOrInBin() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC) + .binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC) + .binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin3", 51, Long.MAX_VALUE); + Exp exp = Exp.or( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseDslExpressionAndCompare( + ExpressionContext.of("($.intBin1 in [1, 2] or $.intBin2 == 100) and $.intBin3 > 50"), + filter, exp, IndexContext.withBinHint(NAMESPACE, indexes, "intBin1")); + } } From 2b660bcf93d554dc57525fbcb863e3be030455b8 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 9 Mar 2026 23:18:02 +0100 Subject: [PATCH 14/14] Add IN variable validation, tests and refactoring. --- .../dsl/parts/ExpressionContainer.java | 22 ++- .../visitor/ExpressionConditionVisitor.java | 181 +++++++++++++++--- .../dsl/expression/InCompositeTests.java | 169 ++++++++++++++++ .../dsl/expression/InNegativeTests.java | 177 +++++++++++++++++ 4 files changed, 522 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java b/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java index 6f0d817..0f737c0 100644 --- a/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java +++ b/src/main/java/com/aerospike/dsl/parts/ExpressionContainer.java @@ -4,6 +4,8 @@ import lombok.Setter; import lombok.experimental.Accessors; +import java.util.EnumSet; + @Getter public class ExpressionContainer extends AbstractPart { @@ -83,6 +85,24 @@ public enum ExprPartsOperation { WHEN_STRUCTURE, // unary EXCLUSIVE_STRUCTURE, // unary AND_STRUCTURE, - OR_STRUCTURE + OR_STRUCTURE; + + // New values not in this set default to "might produce a list" (no false positives). + private static final EnumSet SCALAR = EnumSet.of( + ADD, SUB, MUL, DIV, MOD, POW, + INT_AND, INT_OR, INT_XOR, INT_NOT, + L_SHIFT, R_SHIFT, LOGICAL_R_SHIFT, + ABS, CEIL, FLOOR, LOG, + MIN_FUNC, MAX_FUNC, + COUNT_ONE_BITS, FIND_BIT_LEFT, FIND_BIT_RIGHT, + TO_INT, TO_FLOAT, + EQ, NOTEQ, GT, GTEQ, LT, LTEQ, + IN, NOT, AND, OR, + AND_STRUCTURE, OR_STRUCTURE, EXCLUSIVE_STRUCTURE + ); + + public boolean isScalar() { + return SCALAR.contains(this); + } } } diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index ea94e0b..b5175c5 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -31,7 +31,10 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.SortedMap; import java.util.TreeMap; @@ -40,20 +43,30 @@ public class ExpressionConditionVisitor extends ConditionBaseVisitor { + private int withNestingDepth = 0; + @Override public AbstractPart visitWithExpression(ConditionParser.WithExpressionContext ctx) { - List expressions = new ArrayList<>(); + withNestingDepth++; + try { + List expressions = new ArrayList<>(); - // iterate through each definition - for (ConditionParser.VariableDefinitionContext vdc : ctx.variableDefinition()) { - AbstractPart part = visit(vdc.expression()); - WithOperand withOperand = new WithOperand(part, vdc.stringOperand().getText()); - expressions.add(withOperand); + // iterate through each definition + for (ConditionParser.VariableDefinitionContext vdc : ctx.variableDefinition()) { + AbstractPart part = visit(vdc.expression()); + WithOperand withOperand = new WithOperand(part, vdc.stringOperand().getText()); + expressions.add(withOperand); + } + // last expression is the action (described after "do") + expressions.add(new WithOperand(visit(ctx.expression()), true)); + if (withNestingDepth == 1) { + validateInVariableBindings(expressions); + } + return new ExpressionContainer(new WithStructure(expressions), + ExpressionContainer.ExprPartsOperation.WITH_STRUCTURE); + } finally { + withNestingDepth--; } - // last expression is the action (described after "do") - expressions.add(new WithOperand(visit(ctx.expression()), true)); - return new ExpressionContainer(new WithStructure(expressions), - ExpressionContainer.ExprPartsOperation.WITH_STRUCTURE); } @Override @@ -319,13 +332,9 @@ private static void inferInTypes(AbstractPart left, AbstractPart right) { "IN operation requires a List as the right operand"); } } else if (right.getPartType() == AbstractPart.PartType.PATH_OPERAND) { - Path rightPath = (Path) right; - if (rightPath.getPathFunction() != null) { - Exp.Type pathType = rightPath.getPathFunction().getBinType(); - if (pathType != null && pathType != Exp.Type.LIST) { - throw new DslParseException( - "IN operation requires a List as the right operand"); - } + if (isPathExplicitlyNonList((Path) right)) { + throw new DslParseException( + "IN operation requires a List as the right operand"); } } Exp.Type inferredType = VisitorUtils.validateListHomogeneity(right); @@ -374,33 +383,153 @@ private static boolean isLeftTypeAmbiguous(AbstractPart left) { return false; } + private enum PathListClassification { DEFINITELY_LIST, DEFINITELY_NOT_LIST, UNKNOWN } + /** - * A PATH_OPERAND is ambiguous unless it has: + * Classifies a PATH_OPERAND as definitely a list, definitely not a list, or unknown. + *

    + * Classification is based on (checked in order): *

      - *
    • a path function with an explicit binType (e.g. {@code .get(type: X)}), or
    • - *
    • a COUNT or SIZE path function (returns INT), or
    • - *
    • a type designator as the last CDT part ({@code []} or {@code {}}).
    • + *
    • a path function with an explicit binType — LIST vs non-LIST,
    • + *
    • a COUNT or SIZE path function — always INT (DEFINITELY_NOT_LIST),
    • + *
    • a list type designator ({@code []}) as the last CDT part — DEFINITELY_LIST,
    • + *
    • a map type designator ({@code {}}) as the last CDT part — DEFINITELY_NOT_LIST.
    • *
    + * Falls back to UNKNOWN when none of the above applies. */ - private static boolean isPathTypeAmbiguous(Path path) { + private static PathListClassification classifyPathListness(Path path) { PathFunction pathFunc = path.getPathFunction(); if (pathFunc != null) { // CAST is also covered here: always constructed with a non-null binType if (pathFunc.getBinType() != null) { - return false; + return pathFunc.getBinType() == Exp.Type.LIST + ? PathListClassification.DEFINITELY_LIST + : PathListClassification.DEFINITELY_NOT_LIST; } PathFunction.PathFunctionType pathFuncType = pathFunc.getPathFunctionType(); if (pathFuncType == PathFunction.PathFunctionType.COUNT || pathFuncType == PathFunction.PathFunctionType.SIZE) { - return false; + return PathListClassification.DEFINITELY_NOT_LIST; } } List cdtParts = path.getBasePath().getCdtParts(); if (!cdtParts.isEmpty()) { AbstractPart lastCdt = cdtParts.get(cdtParts.size() - 1); - return !(lastCdt instanceof ListTypeDesignator) && !(lastCdt instanceof MapTypeDesignator); + if (lastCdt instanceof ListTypeDesignator) { + return PathListClassification.DEFINITELY_LIST; + } + if (lastCdt instanceof MapTypeDesignator) { + return PathListClassification.DEFINITELY_NOT_LIST; + } + } + return PathListClassification.UNKNOWN; + } + + private static boolean isPathTypeAmbiguous(Path path) { + return classifyPathListness(path) == PathListClassification.UNKNOWN; + } + + private static boolean isPathExplicitlyNonList(Path path) { + return classifyPathListness(path) == PathListClassification.DEFINITELY_NOT_LIST; + } + + /** + * Validates that variables used as the right operand of an IN expression + * within a WITH block are bound to list-compatible values. + *

    + * Variable definitions are known at parse time, so scalar/map bindings + * can be rejected early instead of deferring to server-side failure. + */ + private static void validateInVariableBindings(List expressions) { + // WITH block always has at least one variable definition (grammar-enforced) + Map varBindings = new HashMap<>(); + for (WithOperand operand : expressions) { + if (!operand.isLastPart()) { + validateInVariablesInTree(operand.getPart(), varBindings); + varBindings.put(operand.getString(), operand.getPart()); + } + } + validateInVariablesInTree(getWithBody(expressions), varBindings); + } + + private static AbstractPart getWithBody(List operands) { + return operands.get(operands.size() - 1).getPart(); + } + + // Handles every AbstractPart subclass that can contain expression children: + // ExpressionContainer, WithStructure, And/Or/ExclusiveStructure, WhenStructure, FunctionArgs. + // Leaf types (operands, BinPart, Path, etc.) are terminal — no recursion needed. + // When adding a new composite AbstractPart subclass, add a branch here. + private static void validateInVariablesInTree(AbstractPart part, + Map varBindings) { + if (part instanceof ExpressionContainer expr) { + validateInVariableIsListCompatible(expr, varBindings); + if (expr.getLeft() != null) { + validateInVariablesInTree(expr.getLeft(), varBindings); + } + if (!expr.isUnary() && expr.getRight() != null) { + validateInVariablesInTree(expr.getRight(), varBindings); + } + } else if (part instanceof WithStructure ws) { + Map merged = new HashMap<>(varBindings); + for (WithOperand op : ws.getOperands()) { + if (!op.isLastPart()) { + validateInVariablesInTree(op.getPart(), merged); + merged.put(op.getString(), op.getPart()); + } + } + validateInVariablesInTree(getWithBody(ws.getOperands()), merged); + } else if (part instanceof AndStructure s) { + s.getOperands().forEach(op -> validateInVariablesInTree(op, varBindings)); + } else if (part instanceof OrStructure s) { + s.getOperands().forEach(op -> validateInVariablesInTree(op, varBindings)); + } else if (part instanceof ExclusiveStructure s) { + s.getOperands().forEach(op -> validateInVariablesInTree(op, varBindings)); + } else if (part instanceof WhenStructure s) { + s.getOperands().forEach(op -> validateInVariablesInTree(op, varBindings)); + } else if (part instanceof FunctionArgs fa) { + fa.getOperands().forEach(op -> validateInVariablesInTree(op, varBindings)); } - return true; + } + + private static void validateInVariableIsListCompatible(ExpressionContainer expr, + Map varBindings) { + if (expr.getOperationType() != ExpressionContainer.ExprPartsOperation.IN) return; + if (expr.getRight() == null) return; + if (expr.getRight().getPartType() != AbstractPart.PartType.VARIABLE_OPERAND) return; + + String varName = ((VariableOperand) expr.getRight()).getValue(); + AbstractPart boundPart = varBindings.get(varName); + if (boundPart != null && isNotList(boundPart)) { + throw new DslParseException( + "IN operation requires a List as the right operand; " + + "variable '" + varName + "' contains a non-List type"); + } + } + + private static final EnumSet NOT_LIST_TYPES = EnumSet.of( + AbstractPart.PartType.INT_OPERAND, + AbstractPart.PartType.FLOAT_OPERAND, + AbstractPart.PartType.BOOL_OPERAND, + AbstractPart.PartType.STRING_OPERAND, + AbstractPart.PartType.MAP_OPERAND, + AbstractPart.PartType.METADATA_OPERAND + ); + + private static boolean isNotList(AbstractPart part) { + if (NOT_LIST_TYPES.contains(part.getPartType())) { + return true; + } + if (part instanceof BinPart bin) { + return bin.isTypeExplicitlySet() && bin.getExpType() != Exp.Type.LIST; + } + if (part.getPartType() == AbstractPart.PartType.PATH_OPERAND) { + return isPathExplicitlyNonList((Path) part); + } + if (part instanceof ExpressionContainer expr && expr.getOperationType() != null) { + return expr.getOperationType().isScalar(); + } + return false; } @Override diff --git a/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java index e76d8f8..f26200e 100644 --- a/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java @@ -1,6 +1,7 @@ package com.aerospike.dsl.expression; import com.aerospike.dsl.ExpressionContext; +import com.aerospike.dsl.PlaceholderValues; import com.aerospike.dsl.client.cdt.ListReturnType; import com.aerospike.dsl.client.exp.Exp; import com.aerospike.dsl.client.exp.ListExp; @@ -86,6 +87,76 @@ void notWrappingIn() { ExpressionContext.of("not($.name in [\"Bob\", \"Mary\"])"), expected); } + @Test + void nestedWithOuterListVar() { + Exp expected = Exp.let( + Exp.def("x", Exp.val(List.of("a", "b"))), + Exp.let( + Exp.def("y", Exp.val(3)), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.var("x")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = [\"a\", \"b\"]) do " + + "(with(y = 3) do ($.name.get(type: STRING) in ${x}))"), expected); + } + + @Test + void nestedWithShadowedVar() { + Exp expected = Exp.let( + Exp.def("x", Exp.val(1)), + Exp.let( + Exp.def("x", Exp.val(List.of("a"))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.var("x")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = 1) do " + + "(with(x = [\"a\"]) do ($.name.get(type: STRING) in ${x}))"), expected); + } + + @Test + void nestedWithVarBoundToVar() { + Exp expected = Exp.let( + Exp.def("x", Exp.val(List.of(1, 2))), + Exp.let( + Exp.def("y", Exp.var("x")), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(1), Exp.var("y")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = [1, 2]) do " + + "(with(y = ${x}) do (1 in ${y}))"), expected); + } + + // Known limitation: transitive variable indirection is not resolved statically. + // y -> ${x} where x = 1 (scalar) — the analysis conservatively allows this + // because it cannot follow variable-to-variable bindings. + @Test + void transitiveVarIndirection() { + Exp expected = Exp.let( + Exp.def("x", Exp.val(1)), + Exp.let( + Exp.def("y", Exp.var("x")), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("foo"), Exp.var("y")))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = 1) do " + + "(with(y = ${x}) do (\"foo\" in ${y}))"), expected); + } + + // Known limitation: WHEN_STRUCTURE return type is not analyzed branch-by-branch, + // so a WHEN that always returns a scalar is conservatively allowed as right operand of IN. + @Test + void whenScalarBranchesAllowedConservatively() { + Exp expected = Exp.let( + Exp.def("x", Exp.cond( + Exp.val(true), Exp.val(1), + Exp.val(2))), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.val("foo"), Exp.var("x"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(x = when(true => 1, default => 2))" + + " do (\"foo\" in ${x})"), expected); + } + @Test void arithmeticExprAsLeftIn() { Exp expected = ListExp.getByValue(ListReturnType.EXISTS, @@ -94,4 +165,102 @@ void arithmeticExprAsLeftIn() { parseFilterExpressionAndCompare( ExpressionContext.of("$.a + 5 in [10, 20, 30]"), expected); } + + @Test + void inWithIntTypeInWhenCond() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("age"), Exp.val(List.of(18, 21))), + Exp.val("eligible"), + Exp.val("ineligible")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.age.get(type: INT) in [18, 21] => \"eligible\"," + + " default => \"ineligible\")"), expected); + } + + @Test + void multipleInConditionsInWhen() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("role"), Exp.val(List.of("admin"))), + Exp.val(1), + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("role"), Exp.val(List.of("user"))), + Exp.val(2), + Exp.val(0)); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.role in [\"admin\"] => 1," + + " $.role in [\"user\"] => 2," + + " default => 0)"), expected); + } + + @Test + void mixedInAndComparisonInWhen() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob", "Mary"))), + Exp.val("known"), + Exp.gt(Exp.intBin("age"), Exp.val(65)), + Exp.val("senior"), + Exp.val("other")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.name in [\"Bob\", \"Mary\"] => \"known\"," + + " $.age > 65 => \"senior\"," + + " default => \"other\")"), expected); + } + + @Test + void inWithBinRightInWhenCond() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("status"), Exp.listBin("allowedStatuses")), + Exp.val("ok"), + Exp.val("rejected")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.status.get(type: INT) in $.allowedStatuses" + + " => \"ok\", default => \"rejected\")"), expected); + } + + @Test + void whenResultWithInCondition() { + Exp expected = Exp.eq( + Exp.stringBin("label"), + Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.val("VIP"), + Exp.val("regular"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.label.get(type: STRING) == " + + "(when($.name in [\"Bob\"] => \"VIP\", default => \"regular\"))"), + expected); + } + + @Test + void inInsideWhenWithVariable() { + Exp expected = Exp.let( + Exp.def("allowed", Exp.val(List.of("Bob", "Mary"))), + Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.var("allowed")), + Exp.val("found"), + Exp.val("missing"))); + parseFilterExpressionAndCompare( + ExpressionContext.of("with(allowed = [\"Bob\", \"Mary\"]) do " + + "(when($.name.get(type: STRING) in ${allowed} => \"found\"," + + " default => \"missing\"))"), expected); + } + + @Test + void inInsideWhenWithPlaceholder() { + Exp expected = Exp.cond( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.stringBin("name"), Exp.val(List.of("Bob"))), + Exp.val("match"), + Exp.val("no match")); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.name.get(type: STRING) in ?0 => \"match\"," + + " default => \"no match\")", + PlaceholderValues.of(List.of("Bob"))), expected); + } } diff --git a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java index 0d74601..c211f31 100644 --- a/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -206,6 +206,154 @@ void negBinInPlaceholderAmb() { .hasMessageContaining("cannot infer the type of the left operand for IN operation"); } + @Test + void negVarBoundToInt() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = 1) do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToFloat() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = 1.5) do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToBool() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = true) do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToString() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = \"hello\") do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = {\"a\": 1}) do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToMetadata() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = $.ttl()) do (\"100\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + // --- Variable bound to expression (scalar-producing) --- + + @Test + void negVarBoundToArithmeticExpr() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = 1 + 2) do (\"foo\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToFunctionExpr() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("with(x = abs(1)) do (\"foo\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + // --- Variable bound to explicitly typed bin/path (non-LIST) --- + + @Test + void negVarBoundToExplicitIntBin() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = $.someBin.get(type: INT)) do (\"foo\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToExplicitStrPath() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = $.a.b.get(type: STRING)) do (\"foo\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + // --- Nested WITH variable validation --- + + @Test + void negNestedWithOuterNonListVar() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = 1) do (with(y = [1, 2]) do (\"foo\" in ${x}))"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negNestedShadowedVarWithScalar() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = [1]) do (with(x = 1) do (\"foo\" in ${x}))"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negNotWrappingInWithScalarVar() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = 1) do (not(\"foo\" in ${x}))"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarBoundToCountPath() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = $.a.[].count()) do (\"foo\" in ${x})"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + + @Test + void negVarDefWithInScalarVar() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of( + "with(x = 5, y = ($.bin.get(type: INT) in ${x})) do (y == true)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand") + .hasMessageContaining("variable 'x'"); + } + @Test void negBinInVariableAmbiguous() { assertThatThrownBy(() -> parseFilterExp( @@ -275,4 +423,33 @@ void negPlaceholderIndexOutOfBounds() { .isInstanceOf(DslParseException.class) .hasMessageContaining("Missing value for placeholder ?1"); } + + // --- IN inside WHEN (regression) --- + + @Test + void negAmbiguousLeftInWhenCond() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("when($.name in $.allowedNames => \"ok\"," + + " default => \"no\")"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negNonListRightInWhenCond() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("when($.name.get(type: STRING) in \"Bob\" => \"ok\"," + + " default => \"no\")"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN operation requires a List as the right operand"); + } + + @Test + void negMixedTypeListInWhenCond() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("when($.name in [1, \"hello\"] => \"ok\"," + + " default => \"no\")"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } }