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 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/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index 16f649b..f8ae60b 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,9 @@ stringOperand: QUOTED_STRING; QUOTED_STRING: ('\'' (~'\'')* '\'') | ('"' (~'"')* '"'); -listConstant: '[' unaryExpression? (',' unaryExpression)* ']'; +// 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. +listConstant: '[' unaryExpression? (',' unaryExpression)* ']' | LIST_TYPE_DESIGNATOR; orderedMapConstant: '{' mapPairConstant? (',' mapPairConstant)* '}'; @@ -215,7 +218,7 @@ PATH_FUNCTION_CDT_RETURN_TYPE | 'REVERSE_RANK' ; -binPart: NAME_IDENTIFIER; +binPart: NAME_IDENTIFIER | IN; mapPart : MAP_TYPE_DESIGNATOR @@ -238,6 +241,7 @@ MAP_TYPE_DESIGNATOR: '{}'; mapKey : NAME_IDENTIFIER | QUOTED_STRING + | IN ; mapValue: '{=' valueIdentifier '}'; @@ -490,6 +494,7 @@ valueIdentifier : NAME_IDENTIFIER | QUOTED_STRING | signedInt + | IN ; valueListIdentifier: valueIdentifier ',' valueIdentifier (',' valueIdentifier)*; @@ -532,6 +537,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..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 { @@ -78,10 +80,29 @@ public enum ExprPartsOperation { GTEQ, LT, LTEQ, + IN, WITH_STRUCTURE, // unary 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/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/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/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/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 4c9de26..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,13 +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()); - } - 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/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/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/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 8440693..cedea87 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -1,18 +1,25 @@ package com.aerospike.dsl.parts.operand; +import com.aerospike.dsl.DslParseException; 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 +32,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}. @@ -45,9 +54,25 @@ 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 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 { + @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); + } } 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/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index 0ed8b2b..ef75653 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,79 @@ 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). + * + * @param ctx The mapKey context from the parser + * @return The parsed key string + */ + public static String parseMapKey(ConditionParser.MapKeyContext ctx) { + String result = resolveStringToken(ctx); + if (result != null) { + return result; + } + 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. + * + * @param ctx The valueIdentifier context from the parser + * @return The parsed value as String or Integer + */ + public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext ctx) { + String result = resolveStringToken(ctx); + if (result != null) { + return result; + } + if (ctx.signedInt() != null) { + return parseSignedInt(ctx.signedInt()); + } + 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 a12209d..b5175c5 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -30,7 +30,11 @@ import org.antlr.v4.runtime.tree.RuleNode; 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; @@ -39,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 @@ -259,6 +273,265 @@ 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); + } + + /** + * 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"); + } + 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"); + } + + /** + * 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: 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. + */ + private static void inferInTypes(AbstractPart left, AbstractPart right) { + if (right.getPartType() == AbstractPart.PartType.BIN_PART) { + 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"); + } + } else if (right.getPartType() == AbstractPart.PartType.PATH_OPERAND) { + if (isPathExplicitlyNonList((Path) right)) { + throw new DslParseException( + "IN operation requires a List as the right operand"); + } + } + 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; + } + + private enum PathListClassification { DEFINITELY_LIST, DEFINITELY_NOT_LIST, UNKNOWN } + + /** + * 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 — 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 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 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 PathListClassification.DEFINITELY_NOT_LIST; + } + } + List cdtParts = path.getBasePath().getCdtParts(); + if (!cdtParts.isEmpty()) { + AbstractPart lastCdt = cdtParts.get(cdtParts.size() - 1); + 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)); + } + } + + 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 public AbstractPart visitGreaterThanExpression(ConditionParser.GreaterThanExpressionContext ctx) { AbstractPart left = visit(ctx.bitwiseExpression(0)); @@ -568,7 +841,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 @@ -578,6 +857,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 463b6a7..37d9cb0 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; @@ -18,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; @@ -1123,12 +1126,18 @@ 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 validateListHomogeneity} enforces uniform element types + * and {@code inferBinTypeFromList} 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 @@ -1139,13 +1148,16 @@ private static void replacePlaceholdersInExprContainer(AbstractPart part, Placeh boolean rightIsPlaceholder = !expr.isUnary() && expr.getRight().getPartType() == PLACEHOLDER_OPERAND; boolean isResolved = false; - // Resolve left placeholder and replace it with the resolved operand + if (expr.getOperationType() == IN && rightIsPlaceholder) { + validateInPlaceholderValue((PlaceholderOperand) expr.getRight(), placeholderValues); + } + 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; @@ -1154,6 +1166,133 @@ 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) { + Exp.Type inferredType = validateListHomogeneity(expr.getRight()); + inferBinTypeFromList(expr.getLeft(), inferredType); + } + } + + /** + * 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 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; + 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); + } + } + + /** + * Validates that all elements in a LIST_OPERAND are of the same type. + *

    + * 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 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 Exp.Type validateListHomogeneity(AbstractPart right) { + if (right.getPartType() != LIST_OPERAND) { + return 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 { + 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 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(); + 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; } /** @@ -1336,6 +1475,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); @@ -1447,6 +1591,19 @@ && 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); + return ListExp.getByValue(ListReturnType.EXISTS, leftExp, rightExp); + } + private static boolean isArithmeticExpressionContainer(AbstractPart part) { return part instanceof ExpressionContainer container && container.getOperationType() != null @@ -1635,6 +1792,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/InBinTests.java b/src/test/java/com/aerospike/dsl/expression/InBinTests.java new file mode 100644 index 0000000..4d14328 --- /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.get(type: INT) 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.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 new file mode 100644 index 0000000..f26200e --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InCompositeTests.java @@ -0,0 +1,266 @@ +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.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.get(type: INT) 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 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, + 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 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/InExplicitTypeTests.java b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java new file mode 100644 index 0000000..17196c0 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InExplicitTypeTests.java @@ -0,0 +1,473 @@ +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.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.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.get(type: INT) 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); + } + + // --- 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( + 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/InGrammarConflictTests.java b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java new file mode 100644 index 0000000..745845e --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InGrammarConflictTests.java @@ -0,0 +1,181 @@ +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); + 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); + } + + @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); + } + +} 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..6a5c9a3 --- /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.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 explicitIntBinInListDesignator() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("name"), Exp.listBin("binName")); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.name.get(type: INT) in $.binName.[]"), 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..c211f31 --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/InNegativeTests.java @@ -0,0 +1,455 @@ +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.List; +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("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPlaceholderResolvesToInt() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(42)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPlaceholderResolvesToFloat() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(1.5)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPlaceholderResolvesToBool() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", PlaceholderValues.of(true)))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @Test + void negPlaceholderResolvesToMap() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name in ?0", + PlaceholderValues.of(Map.of("a", 1))))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("cannot infer the type of the left operand for IN operation"); + } + + @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.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"); + } + + @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"); + } + + // --- 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 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( + 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.get(type: STRING) in ?0", PlaceholderValues.of()))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Missing value for placeholder ?0"); + } + + @Test + void negPlaceholderIndexOutOfBounds() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.name.get(type: STRING) in ?1", PlaceholderValues.of(42)))) + .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"); + } +} 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..024f6c6 --- /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.get(type: STRING) 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.get(type: INT) 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.get(type: FLOAT) 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.get(type: BOOL) 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.get(type: LIST) 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.get(type: MAP) 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.get(type: INT) 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/InFilterTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java new file mode 100644 index 0000000..a2d566b --- /dev/null +++ b/src/test/java/com/aerospike/dsl/parsedExpression/InFilterTests.java @@ -0,0 +1,867 @@ +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)); + } + + // --- 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")); + } +} diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java index 241a90d..f61baf7 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java @@ -314,4 +314,12 @@ void binLogical_EXCL_no_indexes() { TestUtils.parseDslExpressionAndCompare(ExpressionContext.of("exclusive($.hand == ?0, $.pun == ?1)", 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"); + } +}