From b8ef21b2590f8f40534d1356a1d8420057036e92 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 3 Apr 2026 22:47:19 +0200 Subject: [PATCH 1/2] Support BLOB language element with hex and Base64 formats --- .../antlr4/com/aerospike/dsl/Condition.g4 | 10 +- .../client/fluent/AerospikeComparator.java | 187 ++++++ .../com/aerospike/dsl/parts/AbstractPart.java | 3 +- .../parts/cdt/list/ListRankRangeRelative.java | 11 +- .../parts/cdt/map/MapRankRangeRelative.java | 11 +- .../dsl/parts/operand/BlobOperand.java | 21 + .../dsl/parts/operand/OperandFactory.java | 11 +- .../dsl/parts/operand/StringOperand.java | 10 +- .../com/aerospike/dsl/util/ParsingUtils.java | 37 +- .../visitor/ExpressionConditionVisitor.java | 18 +- .../aerospike/dsl/visitor/VisitorUtils.java | 74 ++- .../java/com/aerospike/dsl/ctx/CtxTests.java | 14 + .../aerospike/dsl/expression/BlobTests.java | 536 ++++++++++++++++++ .../dsl/filter/ExplicitTypesFiltersTests.java | 64 +++ .../parsedExpression/PlaceholdersTests.java | 45 ++ .../parts/operand/OperandFactoryTests.java | 21 +- 16 files changed, 1033 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/aerospike/dsl/client/fluent/AerospikeComparator.java create mode 100644 src/main/java/com/aerospike/dsl/parts/operand/BlobOperand.java create mode 100644 src/test/java/com/aerospike/dsl/expression/BlobTests.java diff --git a/src/main/antlr4/com/aerospike/dsl/Condition.g4 b/src/main/antlr4/com/aerospike/dsl/Condition.g4 index 8e75638..4189a15 100644 --- a/src/main/antlr4/com/aerospike/dsl/Condition.g4 +++ b/src/main/antlr4/com/aerospike/dsl/Condition.g4 @@ -83,6 +83,7 @@ operand | numberOperand | booleanOperand | stringOperand + | blobOperand | listConstant | orderedMapConstant | variable @@ -134,6 +135,11 @@ stringOperand: QUOTED_STRING; QUOTED_STRING: ('\'' (~'\'')* '\'') | ('"' (~'"')* '"'); +blobOperand: BLOB_LITERAL | B64_LITERAL; + +BLOB_LITERAL: [xX] '\'' [0-9a-fA-F]* '\''; +B64_LITERAL: [bB] '64\'' [A-Za-z0-9+/=]* '\''; + // 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; @@ -142,7 +148,7 @@ orderedMapConstant: '{' mapPairConstant? (',' mapPairConstant)* '}'; mapPairConstant: mapKeyOperand ':' unaryExpression; -mapKeyOperand: intOperand | stringOperand; +mapKeyOperand: intOperand | stringOperand | blobOperand; variable: VARIABLE_REFERENCE; @@ -499,6 +505,8 @@ valueIdentifier | QUOTED_STRING | signedInt | IN + | BLOB_LITERAL + | B64_LITERAL ; valueListIdentifier: valueIdentifier ',' valueIdentifier (',' valueIdentifier)*; diff --git a/src/main/java/com/aerospike/dsl/client/fluent/AerospikeComparator.java b/src/main/java/com/aerospike/dsl/client/fluent/AerospikeComparator.java new file mode 100644 index 0000000..e2640ac --- /dev/null +++ b/src/main/java/com/aerospike/dsl/client/fluent/AerospikeComparator.java @@ -0,0 +1,187 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.aerospike.dsl.client.fluent; + +import com.aerospike.dsl.client.Value; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * Comparator that orders objects according to the Aerospike server's + * type ordering hierarchy: + * NIL(1) < BOOLEAN(2) < INTEGER(3) < STRING(4) < LIST(5) < MAP(6) + * < BYTES(7) < DOUBLE(8) < GEOJSON(9). + *

+ * Cross-type comparison is strictly by type ordinal — there is no + * numeric promotion between INTEGER and DOUBLE. + * + * @see + * Aerospike type ordering + */ +public class AerospikeComparator implements Comparator { + enum AsType { + NULL (1), + BOOLEAN (2), + INTEGER (3), + STRING (4), + LIST (5), + MAP (6), + BYTES (7), + DOUBLE (8), + GEOJSON (9), + OTHER (10); + + private final int value; + AsType(int value) { + this.value = value; + } + + public int getOrdinal() { + return value; + } + } + + private final boolean caseSensitiveStrings; + + public AerospikeComparator() { + this(true); + } + + public AerospikeComparator(boolean caseSensitiveStrings) { + this.caseSensitiveStrings = caseSensitiveStrings; + } + + private boolean isByteType(Class clazz) { + return Byte.class.equals(clazz) || + Byte.TYPE.equals(clazz); + } + + private boolean isIntegerType(Object o) { + return ((o instanceof Byte) || (o instanceof Character) || (o instanceof Short) || (o instanceof Integer) || (o instanceof Long)); + } + private boolean isFloatType(Object o) { + return ((o instanceof Float) || (o instanceof Double)); + } + + AsType getType(Object o) { + if (o == null) { return AsType.NULL; } + else if (o instanceof Boolean) { return AsType.BOOLEAN; } + else if (isIntegerType(o)) { return AsType.INTEGER; } + else if (o instanceof String) { return AsType.STRING; } + else if (o instanceof List) { return AsType.LIST; } + else if (o instanceof Map) { return AsType.MAP; } + else if (o instanceof Value.HLLValue) { return AsType.BYTES; } + else if (o.getClass().isArray() && isByteType(o.getClass().getComponentType())) { return AsType.BYTES; } + else if (isFloatType(o)) { return AsType.DOUBLE; } + else if (o instanceof Value.GeoJSONValue) { return AsType.GEOJSON; } + else { + return AsType.OTHER; + } + } + + private byte[] toByteArray(Object o) { + if (o instanceof Value.HLLValue) { + return ((Value.HLLValue) o).getBytes(); + } + return (byte[]) o; + } + + private int compareList(List l1, List l2) { + int l1Size = l1.size(); + int l2Size = l2.size(); + for (int index = 0; index < l1Size; index++) { + if (index >= l2Size) { + return 1; + } + int result = compare(l1.get(index), l2.get(index)); + if (result != 0) { + return result; + } + } + return l1Size == l2Size ? 0 : -1; + } + + private int compareMap(Map m1, Map m2) { + if (m1.size() == m2.size()) { + List sortedKeys1 = new ArrayList<>(m1.keySet()); + sortedKeys1.sort(this); + List sortedKeys2 = new ArrayList<>(m2.keySet()); + sortedKeys2.sort(this); + int result = compareList(sortedKeys1, sortedKeys2); + if (result != 0) { + return result; + } + for (int i = 0; i < sortedKeys1.size(); i++) { + Object v1 = m1.get(sortedKeys1.get(i)); + Object v2 = m2.get(sortedKeys2.get(i)); + result = this.compare(v1, v2); + if (result != 0) { + return result; + } + } + return 0; + } + else { + return m1.size() - m2.size(); + } + } + + @Override + @SuppressWarnings("unchecked") + public int compare(Object o1, Object o2) { + AsType t1 = getType(o1); + AsType t2 = getType(o2); + if (t1.getOrdinal() != t2.getOrdinal()) { + return t1.getOrdinal() - t2.getOrdinal(); + } + + switch (t1) { + case NULL: + return 0; + case BOOLEAN: + return Boolean.compare((Boolean)o1, (Boolean)o2); + case INTEGER: + return Long.compare(((Number)o1).longValue(), ((Number)o2).longValue()); + case STRING: + if (caseSensitiveStrings) { + return ((String)o1).compareTo((String)o2); + } + else { + return ((String)o1).compareToIgnoreCase((String)o2); + } + case LIST: + return compareList((List)o1, (List)o2); + case MAP: + return compareMap((Map)o1, (Map)o2); + case BYTES: + return Arrays.compare(toByteArray(o1), toByteArray(o2)); + case DOUBLE: + return Double.compare(((Number)o1).doubleValue(), ((Number)o2).doubleValue()); + case GEOJSON: + return o1.toString().compareTo(o2.toString()); + case OTHER: + default: + throw new UnsupportedOperationException( + "Cannot compare objects of type: " + o1.getClass().getName()); + } + } +} diff --git a/src/main/java/com/aerospike/dsl/parts/AbstractPart.java b/src/main/java/com/aerospike/dsl/parts/AbstractPart.java index 582116b..28830f9 100644 --- a/src/main/java/com/aerospike/dsl/parts/AbstractPart.java +++ b/src/main/java/com/aerospike/dsl/parts/AbstractPart.java @@ -45,6 +45,7 @@ public enum PartType { EXPRESSION_CONTAINER, VARIABLE_OPERAND, PLACEHOLDER_OPERAND, - FUNCTION_ARGS + FUNCTION_ARGS, + BLOB_OPERAND } } 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 378f783..a5749ea 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 @@ -7,9 +7,9 @@ import com.aerospike.dsl.client.exp.Exp; 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.objectToExp; import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; import static com.aerospike.dsl.util.ParsingUtils.subtractNullable; @@ -60,14 +60,7 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | ListReturnType.INVERTED; } - Exp relativeExp; - if (relative instanceof String rel) { - relativeExp = Exp.val(rel); - } else if (relative instanceof Integer rel) { - relativeExp = Exp.val(rel); - } else { - throw new DslParseException("Unsupported value relative rank"); - } + Exp relativeExp = objectToExp(relative); Exp startExp = Exp.val(start); if (count == null) { 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 d2caab6..553bfc2 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 @@ -7,9 +7,9 @@ import com.aerospike.dsl.client.exp.Exp; 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.objectToExp; import static com.aerospike.dsl.util.ParsingUtils.parseSignedInt; import static com.aerospike.dsl.util.ParsingUtils.subtractNullable; @@ -60,14 +60,7 @@ public Exp constructExp(BasePath basePath, Exp.Type valueType, int cdtReturnType cdtReturnType = cdtReturnType | MapReturnType.INVERTED; } - Exp relativeExp; - if (relative instanceof String rel) { - relativeExp = Exp.val(rel); - } else if (relative instanceof Integer rel) { - relativeExp = Exp.val(rel); - } else { - throw new DslParseException("Unsupported value relative rank"); - } + Exp relativeExp = objectToExp(relative); Exp startExp = Exp.val(start); if (count == null) { diff --git a/src/main/java/com/aerospike/dsl/parts/operand/BlobOperand.java b/src/main/java/com/aerospike/dsl/parts/operand/BlobOperand.java new file mode 100644 index 0000000..713a9f5 --- /dev/null +++ b/src/main/java/com/aerospike/dsl/parts/operand/BlobOperand.java @@ -0,0 +1,21 @@ +package com.aerospike.dsl.parts.operand; + +import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.parts.AbstractPart; +import lombok.Getter; + +@Getter +public class BlobOperand extends AbstractPart implements ParsedValueOperand { + + private final byte[] value; + + public BlobOperand(byte[] value) { + super(PartType.BLOB_OPERAND); + this.value = value; + } + + @Override + public Exp getExp() { + return Exp.val(value); + } +} 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 2bfb01e..3aab0d3 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/OperandFactory.java @@ -1,6 +1,7 @@ package com.aerospike.dsl.parts.operand; import com.aerospike.dsl.DslParseException; +import com.aerospike.dsl.client.fluent.AerospikeComparator; import com.aerospike.dsl.parts.AbstractPart; import java.util.List; @@ -20,6 +21,7 @@ * @see IntOperand * @see ListOperand * @see MapOperand + * @see BlobOperand */ public interface OperandFactory { @@ -34,6 +36,7 @@ public interface OperandFactory { *
  • {@link Integer} or {@link Long} to {@link IntOperand}.
  • *
  • {@link List} to {@link ListOperand}.
  • *
  • {@link Map} to {@link MapOperand}.
  • + *
  • {@code byte[]} to {@link BlobOperand}.
  • * * * @param value The object to be converted into an operand. This cannot be {@code null}. @@ -62,12 +65,16 @@ static AbstractPart createOperand(Object value) { @SuppressWarnings("unchecked") SortedMap objectMap = (SortedMap) sortedMap; return new MapOperand(objectMap); + } else if (value instanceof byte[] bytes) { + return new BlobOperand(bytes); } else if (value instanceof Map map) { try { @SuppressWarnings("unchecked") Map objectMap = (Map) map; - return new MapOperand(new TreeMap<>(objectMap)); - } catch (ClassCastException | NullPointerException e) { + SortedMap sortedMap = new TreeMap<>(new AerospikeComparator()); + sortedMap.putAll(objectMap); + return new MapOperand(sortedMap); + } catch (ClassCastException | NullPointerException | UnsupportedOperationException e) { throw new DslParseException( "Map keys must be mutually comparable for operand creation", e); } diff --git a/src/main/java/com/aerospike/dsl/parts/operand/StringOperand.java b/src/main/java/com/aerospike/dsl/parts/operand/StringOperand.java index c81ad49..316464a 100644 --- a/src/main/java/com/aerospike/dsl/parts/operand/StringOperand.java +++ b/src/main/java/com/aerospike/dsl/parts/operand/StringOperand.java @@ -1,5 +1,6 @@ package com.aerospike.dsl.parts.operand; +import com.aerospike.dsl.DslParseException; import com.aerospike.dsl.client.exp.Exp; import com.aerospike.dsl.parts.AbstractPart; import lombok.Getter; @@ -22,8 +23,13 @@ public StringOperand(String string) { @Override public Exp getExp() { if (isBlob) { - byte[] byteValue = Base64.getDecoder().decode(value); - return Exp.val(byteValue); + try { + byte[] byteValue = Base64.getDecoder().decode(value); + return Exp.val(byteValue); + } catch (IllegalArgumentException e) { + throw new DslParseException( + "String compared to BLOB-typed path is not valid Base64: " + value, e); + } } return Exp.val(value); } diff --git a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java index c7a0234..d5b5b69 100644 --- a/src/main/java/com/aerospike/dsl/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/dsl/util/ParsingUtils.java @@ -147,9 +147,42 @@ public static Object parseValueIdentifier(ConditionParser.ValueIdentifierContext if (ctx.signedInt() != null) { return parseSignedInt(ctx.signedInt()); } + TerminalNode blobLiteral = ctx.getToken(ConditionParser.BLOB_LITERAL, 0); + if (blobLiteral != null) { + return parseHexToBytes(blobLiteral.getText()); + } + TerminalNode b64Literal = ctx.getToken(ConditionParser.B64_LITERAL, 0); + if (b64Literal != null) { + return parseB64ToBytes(b64Literal.getText()); + } throw new DslParseException("Could not parse valueIdentifier from ctx: %s".formatted(ctx.getText())); } + // Token format: X'hexchars' or x'hexchars' — strip 2-char prefix and trailing quote + public static byte[] parseHexToBytes(String text) { + String hex = text.substring(2, text.length() - 1); + if (hex.length() % 2 != 0) { + throw new DslParseException( + "BLOB literal must contain an even number of hex characters: " + text); + } + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + // Token format: b64'base64chars' or B64'base64chars' — strip 4-char prefix and trailing quote + public static byte[] parseB64ToBytes(String text) { + String b64 = text.substring(4, text.length() - 1); + try { + return java.util.Base64.getDecoder().decode(b64); + } catch (IllegalArgumentException e) { + throw new DslParseException( + "Base64 BLOB literal contains invalid Base64 content: " + text, e); + } + } + /** * 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. @@ -169,7 +202,8 @@ public static Integer requireIntValueIdentifier(ConditionParser.ValueIdentifierC /** * Converts a parsed value object to an {@link Exp} value expression. - * Supports the types produced by {@link #parseValueIdentifier}: {@link String} and {@link Integer}. + * Supports the types produced by {@link #parseValueIdentifier}: {@link String}, {@link Integer}, + * and {@code byte[]}. * * @param value The parsed value object * @return The corresponding {@link Exp} value expression @@ -178,6 +212,7 @@ public static Integer requireIntValueIdentifier(ConditionParser.ValueIdentifierC public static Exp objectToExp(Object value) { if (value instanceof String s) return Exp.val(s); if (value instanceof Integer i) return Exp.val(i); + if (value instanceof byte[] b) return Exp.val(b); throw new DslParseException( "Unsupported value type for Exp conversion: " + value.getClass().getSimpleName()); } diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 9dde00a..47cbecf 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -4,6 +4,7 @@ import com.aerospike.dsl.ConditionParser; import com.aerospike.dsl.DslParseException; import com.aerospike.dsl.client.exp.Exp; +import com.aerospike.dsl.client.fluent.AerospikeComparator; import com.aerospike.dsl.parts.AbstractPart; import com.aerospike.dsl.parts.ExpressionContainer; import com.aerospike.dsl.parts.cdt.list.ListIndex; @@ -18,9 +19,9 @@ import com.aerospike.dsl.parts.cdt.map.*; import com.aerospike.dsl.parts.controlstructure.AndStructure; import com.aerospike.dsl.parts.controlstructure.ExclusiveStructure; +import com.aerospike.dsl.parts.controlstructure.LetStructure; import com.aerospike.dsl.parts.controlstructure.OrStructure; import com.aerospike.dsl.parts.controlstructure.WhenStructure; -import com.aerospike.dsl.parts.controlstructure.LetStructure; import com.aerospike.dsl.parts.operand.*; import com.aerospike.dsl.parts.path.BasePath; import com.aerospike.dsl.parts.path.BinPart; @@ -505,7 +506,8 @@ private static void validateInVariableIsListCompatible(ExpressionContainer expr, AbstractPart.PartType.BOOL_OPERAND, AbstractPart.PartType.STRING_OPERAND, AbstractPart.PartType.MAP_OPERAND, - AbstractPart.PartType.METADATA_OPERAND + AbstractPart.PartType.METADATA_OPERAND, + AbstractPart.PartType.BLOB_OPERAND ); private static boolean isNotList(AbstractPart part) { @@ -892,7 +894,7 @@ public SortedMap getOrderedMapPair(ParseTree ctx) { Object key = ((ParsedValueOperand) visit(ctx.getChild(0))).getValue(); Object value = ((ParsedValueOperand) visit(ctx.getChild(2))).getValue(); - SortedMap map = new TreeMap<>(); + SortedMap map = new TreeMap<>(new AerospikeComparator()); map.put(key, value); return map; @@ -900,7 +902,7 @@ public SortedMap getOrderedMapPair(ParseTree ctx) { public MapOperand readChildrenIntoMapOperand(RuleNode mapNode) { int size = mapNode.getChildCount(); - SortedMap map = new TreeMap<>(); + SortedMap map = new TreeMap<>(new AerospikeComparator()); for (int i = 0; i < size; i++) { ParseTree child = mapNode.getChild(i); if (!shouldVisitMapElement(i, size, child)) { @@ -925,6 +927,14 @@ public AbstractPart visitStringOperand(ConditionParser.StringOperandContext ctx) return new StringOperand(text); } + @Override + public AbstractPart visitBlobOperand(ConditionParser.BlobOperandContext ctx) { + if (ctx.BLOB_LITERAL() != null) { + return new BlobOperand(parseHexToBytes(ctx.BLOB_LITERAL().getText())); + } + return new BlobOperand(parseB64ToBytes(ctx.B64_LITERAL().getText())); + } + @Override public AbstractPart visitNumberOperand(ConditionParser.NumberOperandContext ctx) { // Delegates to specific visit methods diff --git a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java index fe56f5b..e38e1d0 100644 --- a/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/dsl/visitor/VisitorUtils.java @@ -15,16 +15,17 @@ import com.aerospike.dsl.parts.ExpressionContainer.ExprPartsOperation; import com.aerospike.dsl.parts.controlstructure.AndStructure; import com.aerospike.dsl.parts.controlstructure.ExclusiveStructure; +import com.aerospike.dsl.parts.controlstructure.LetStructure; import com.aerospike.dsl.parts.controlstructure.OrStructure; import com.aerospike.dsl.parts.controlstructure.WhenStructure; -import com.aerospike.dsl.parts.controlstructure.LetStructure; +import com.aerospike.dsl.parts.operand.BlobOperand; import com.aerospike.dsl.parts.operand.FunctionArgs; import com.aerospike.dsl.parts.operand.IntOperand; +import com.aerospike.dsl.parts.operand.LetOperand; 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; -import com.aerospike.dsl.parts.operand.LetOperand; import com.aerospike.dsl.parts.path.BinPart; import com.aerospike.dsl.parts.path.Path; import com.aerospike.dsl.util.TypeUtils; @@ -84,7 +85,8 @@ protected enum ArithmeticTermType { AbstractPart.PartType.INT_OPERAND, Exp.Type.INT, AbstractPart.PartType.FLOAT_OPERAND, Exp.Type.FLOAT, AbstractPart.PartType.STRING_OPERAND, Exp.Type.STRING, - AbstractPart.PartType.BOOL_OPERAND, Exp.Type.BOOL + AbstractPart.PartType.BOOL_OPERAND, Exp.Type.BOOL, + AbstractPart.PartType.BLOB_OPERAND, Exp.Type.BLOB ); /** @@ -347,6 +349,10 @@ private static Exp getExpBinComparison(BinPart binPart, AbstractPart anotherPart yield anotherPart.getExp(); } case STRING_OPERAND -> handleStringOperandComparison(binPart, (StringOperand) anotherPart); + case BLOB_OPERAND -> { + validateComparableTypes(binPart.getExpType(), Exp.Type.BLOB); + yield anotherPart.getExp(); + } case METADATA_OPERAND -> { // Handle metadata comparison - type determined by metadata function Exp.Type binType = Exp.Type.valueOf(((MetadataOperand) anotherPart).getMetadataType().toString()); @@ -674,6 +680,14 @@ private static Filter doGetFilterFromBin(BinPart bin, AbstractPart operand, Filt yield getFilterForArithmeticOrFail(binName, ((IntOperand) operand).getValue(), type, ctx); } case STRING_OPERAND -> handleStringOperand(bin, binName, ((StringOperand) operand).getValue(), type, ctx); + case BLOB_OPERAND -> { + validateComparableTypes(bin.getExpType(), Exp.Type.BLOB); + if (type != FilterOperationType.EQ) { + throw new NoApplicableFilterException( + "BLOB filter supports only equality comparison"); + } + yield Filter.equal(binName, ((BlobOperand) operand).getValue(), ctx); + } default -> throw new NoApplicableFilterException( "Operand type not supported: %s".formatted(operand.getPartType())); }; @@ -1025,7 +1039,8 @@ public static AbstractPart buildExpr(ExpressionContainer expr, PlaceholderValues Filter secondaryIndexFilter = null; try { secondaryIndexFilter = getSIFilter(expr, indexes, preferredBin); - } catch (NoApplicableFilterException ignored) { + } catch (NoApplicableFilterException e) { + clearSecondaryIndexFilterFlag(expr); } expr.setFilter(secondaryIndexFilter); @@ -1297,6 +1312,9 @@ private static Exp.Type inferElementType(Object element) { if (element instanceof Map) { return Exp.Type.MAP; } + if (element instanceof byte[]) { + return Exp.Type.BLOB; + } return null; } @@ -1485,6 +1503,9 @@ private static Exp processExpression(ExpressionContainer expr) { return buildInExpression(left, right); } + rejectBlobArithmetic(left, right, expr.getOperationType()); + coerceStringToBlobIfNeeded(left, right); + // Process operands Exp leftExp = processOperand(left); Exp rightExp = processOperand(right); @@ -1517,6 +1538,35 @@ private static Exp processExpression(ExpressionContainer expr) { return operator.apply(leftExp, rightExp); } + private static final EnumSet ARITHMETIC_OPERATIONS = EnumSet.of( + ADD, SUB, MUL, DIV, MOD, POW + ); + + private static void rejectBlobArithmetic(AbstractPart left, AbstractPart right, + ExprPartsOperation opType) { + if (ARITHMETIC_OPERATIONS.contains(opType) + && (resolveExpType(left) == Exp.Type.BLOB + || resolveExpType(right) == Exp.Type.BLOB)) { + throw new DslParseException( + "BLOB type does not support arithmetic operations"); + } + } + + private static void coerceStringToBlobIfNeeded(AbstractPart left, AbstractPart right) { + if (isBlobPath(left) && right.getPartType() == STRING_OPERAND) { + ((StringOperand) right).setBlob(true); + } else if (isBlobPath(right) && left.getPartType() == STRING_OPERAND) { + ((StringOperand) left).setBlob(true); + } + } + + private static boolean isBlobPath(AbstractPart part) { + return part.getPartType() == PATH_OPERAND + && part instanceof Path path + && path.getPathFunction() != null + && path.getPathFunction().getBinType() == Exp.Type.BLOB; + } + // Operations that always produce a FLOAT result. Used by resolveExpType. private static final EnumSet FLOAT_RETURNING_OPERATIONS = EnumSet.of( POW, LOG, CEIL, FLOOR, TO_FLOAT @@ -1701,6 +1751,22 @@ private static Filter getSIFilter(ExpressionContainer expr, Map parseFilterExp( + ExpressionContext.of("X'f' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("even number of hex characters"); + } + + @Test + void negativeOddThreeHexChars() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'abc' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("even number of hex characters"); + } + + @Test + void negativeNonHexChars() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ZZZZ' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Could not parse given DSL expression input"); + } + + @Test + void negativeHexWhitespace() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff 00' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Could not parse given DSL expression input"); + } + + @Test + void negativeInvalidB64Chars() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("b64'!!!!' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Could not parse given DSL expression input"); + } + + @Test + void negativeB64Whitespace() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("b64'AQ ID' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Could not parse given DSL expression input"); + } + + @Test + void negativeInvalidB64Content() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("b64'A' == $.b.get(type: BLOB)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Base64 BLOB literal contains invalid Base64 content"); + } + + // ---- BLOB Bin Comparison ---- + + @Test + void blobHexReversed() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("X'ff00' == $.b.get(type: BLOB)"), + Exp.eq(Exp.val(new byte[]{(byte) 0xff, 0x00}), Exp.blobBin("b"))); + } + + @Test + void blobHexInequality() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) != X'ff00'"), + Exp.ne(Exp.blobBin("b"), Exp.val(new byte[]{(byte) 0xff, 0x00}))); + } + + @Test + void b64BinComparison() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) == b64'AQID'"), + Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{1, 2, 3}))); + } + + @Test + void stringBlobBackwardCompat() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) == \"AQID\""), + Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{1, 2, 3}))); + } + + @Test + void blobOrderingGt() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) > X'ff00'"), + Exp.gt(Exp.blobBin("b"), Exp.val(new byte[]{(byte) 0xff, 0x00}))); + } + + // ---- CDT Path Comparison ---- + + @Test + void cdtPathHexComparison() { + Exp expected = Exp.eq( + ListExp.getByIndex( + ListReturnType.VALUE, Exp.Type.BLOB, + Exp.val(0), Exp.listBin("list")), + Exp.val(new byte[]{1, 2, 3})); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.list.[0].get(type: BLOB) == x'010203'"), expected); + } + + @Test + void cdtPathB64Comparison() { + Expression hexExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == x'010203'"))); + Expression b64Exp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == b64'AQID'"))); + assertThat(hexExp).isEqualTo(b64Exp); + } + + @Test + void cdtPathStringBackCompat() { + Expression hexExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == x'010203'"))); + Expression stringExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == \"AQID\""))); + assertThat(hexExp).isEqualTo(stringExp); + } + + @Test + void cdtPathAllThreeEqual() { + Expression hexExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == x'010203'"))); + Expression b64Exp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == b64'AQID'"))); + Expression strExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.list.[0].get(type: BLOB) == \"AQID\""))); + assertThat(hexExp).isEqualTo(b64Exp).isEqualTo(strExp); + } + + @Test + void cdtPathStringReversed() { + Exp expected = Exp.eq( + Exp.val(new byte[]{1, 2, 3}), + ListExp.getByIndex( + ListReturnType.VALUE, Exp.Type.BLOB, + Exp.val(0), Exp.listBin("list"))); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("\"AQID\" == $.list.[0].get(type: BLOB)"), expected); + } + + // ---- Comparison Edge Cases ---- + + @Test + void negativeBlobVsString() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.blobBin.get(type: BLOB) == $.strBin.get(type: STRING)"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare BLOB to STRING"); + } + + @Test + void stringAsBase64NotHex() { + Expression strExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.b.get(type: BLOB) == \"ff00\""))); + Expression hexExp = Exp.build(parseFilterExp( + ExpressionContext.of("$.b.get(type: BLOB) == X'ff00'"))); + assertThat(strExp).isNotEqualTo(hexExp); + } + + @Test + void negativeInvalidBase64StringVsBlob() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.b.get(type: BLOB) == \"not-base64!!!\""))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("not valid Base64"); + } + + @Test + void getTypeBlobTwoBins() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b1.get(type: BLOB) == $.b2.get(type: BLOB)"), + Exp.eq(Exp.blobBin("b1"), Exp.blobBin("b2"))); + } + + @Test + void negativeBlobVsInt() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.b.get(type: BLOB) == 42"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare BLOB to INT"); + } + + // ---- BLOB in List Constants ---- + + @Test + void listWithHexBlob() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.lb.get(type: LIST) == [X'ff00']"), + Exp.eq(Exp.listBin("lb"), Exp.val(List.of(new byte[]{(byte) 0xff, 0x00})))); + } + + @Test + void listWithBlobAndOtherTypes() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.lb.get(type: LIST) == [X'ff00', 42, \"hello\"]"), + Exp.eq(Exp.listBin("lb"), Exp.val(List.of( + new byte[]{(byte) 0xff, 0x00}, 42L, "hello")))); + } + + @Test + void listWithMultipleBlobs() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.lb.get(type: LIST) == [X'aabb', X'ccdd']"), + Exp.eq(Exp.listBin("lb"), Exp.val(List.of( + new byte[]{(byte) 0xaa, (byte) 0xbb}, + new byte[]{(byte) 0xcc, (byte) 0xdd})))); + } + + @Test + void listWithB64Blob() { + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.lb.get(type: LIST) == [b64'AQID']"), + Exp.eq(Exp.listBin("lb"), Exp.val(List.of(new byte[]{1, 2, 3})))); + } + + // ---- BLOB in IN Expressions ---- + + @Test + void blobBinInBlobList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.blobBin("b"), + Exp.val(List.of(new byte[]{(byte) 0xaa}, new byte[]{(byte) 0xbb}))); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) IN [X'aa', X'bb']"), expected); + } + + @Test + void blobLiteralInBlobList() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.val(new byte[]{(byte) 0xaa}), + Exp.val(List.of( + new byte[]{(byte) 0xaa}, + new byte[]{(byte) 0xbb}, + new byte[]{(byte) 0xcc}))); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("X'aa' IN [X'aa', X'bb', X'cc']"), expected); + } + + @Test + void negativeHeterogeneousInList() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'aa' IN [X'aa', 1]"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("IN list elements must all be of the same type"); + } + + // ---- BLOB as Map Key ---- + + @Test + void mapWithBlobKey() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put(new byte[]{(byte) 0xff}, 42L); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {X'ff': 42}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + @Test + void mapWithMultipleBlobKeys() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put(new byte[]{(byte) 0xaa}, 1L); + expected.put(new byte[]{(byte) 0xbb}, 2L); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {X'aa': 1, X'bb': 2}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + @Test + void mapWithMixedKeyTypes() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put(3L, 4L); + expected.put("key", 2L); + expected.put(new byte[]{(byte) 0xff}, 1L); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {X'ff': 1, \"key\": 2, 3: 4}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + @Test + void mapWithB64BlobKey() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put(new byte[]{1, 2, 3}, 42L); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {b64'AQID': 42}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + // ---- BLOB as Map Value ---- + + @Test + void mapWithBlobValue() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put("key", new byte[]{(byte) 0xff, 0x00}); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {\"key\": X'ff00'}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + @Test + void mapWithBlobKeyAndValue() { + SortedMap expected = new TreeMap<>(new AerospikeComparator()); + expected.put(new byte[]{(byte) 0xaa}, new byte[]{(byte) 0xbb}); + TestUtils.parseFilterExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == {X'aa': X'bb'}"), + Exp.eq(Exp.mapBin("mb"), Exp.val(expected))); + } + + // ---- BLOB in CDT Value Selectors ---- + + @Test + void listValueSelectorWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.lb.[=X'ff00'].get(type: BLOB) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void mapValueSelectorWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.mb.{=X'ff00'}.get(type: BLOB) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void listValueListWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.lb.[=X'aa',X'bb'].get(type: BLOB) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void mapValueListWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.mb.{=X'aa',X'bb'}.get(type: BLOB) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void listRelativeRankWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.lb.[#0:~X'ff00'].get(type: INT) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void mapRelativeRankWithBlob() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.mb.{#0:~X'ff00'}.get(type: INT) == 1"))); + assertThat(actual).isNotNull(); + } + + @Test + void b64CdtValueSelector() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("$.lb.[=b64'AQID'].get(type: BLOB) == 1"))); + assertThat(actual).isNotNull(); + } + + // ---- BLOB Not Supported in Arithmetic ---- + + @Test + void negativeBlobIntArithmetic() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' + 42 == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeSameTypeBlobArith() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' + X'ee' == X'dd'"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeBlobMultiplicative() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' * 2 == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeBlobModulo() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' % 2 == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeBlobPower() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' ** 2 == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeUnaryMinusBlob() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("-X'ff' == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("BLOB type does not support arithmetic operations"); + } + + @Test + void negativeMixedTypeBitwise() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("X'ff' & 42 == 1"))) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Cannot compare BLOB to INT"); + } + + // Not fully supported yet + @Test + void bitwiseSameTypeBlobOk() { + Expression actual = Exp.build(parseFilterExp( + ExpressionContext.of("(X'ff' & X'0f') == 15"))); + assertThat(actual).isNotNull(); + } +} diff --git a/src/test/java/com/aerospike/dsl/filter/ExplicitTypesFiltersTests.java b/src/test/java/com/aerospike/dsl/filter/ExplicitTypesFiltersTests.java index 296c5e2..85b52e4 100644 --- a/src/test/java/com/aerospike/dsl/filter/ExplicitTypesFiltersTests.java +++ b/src/test/java/com/aerospike/dsl/filter/ExplicitTypesFiltersTests.java @@ -4,6 +4,7 @@ import com.aerospike.dsl.ExpressionContext; import com.aerospike.dsl.Index; import com.aerospike.dsl.IndexContext; +import com.aerospike.dsl.client.exp.Exp; import com.aerospike.dsl.client.query.Filter; import com.aerospike.dsl.client.query.IndexType; import com.aerospike.dsl.util.TestUtils; @@ -180,4 +181,67 @@ void negativeTwoDifferentBinTypesComparison() { .isInstanceOf(DslParseException.class) .hasMessage("Cannot compare STRING to FLOAT"); } + + // ---- BLOB Literal Filter with IndexContext ---- + + @Test + void blobHexEqualityFilter() { + byte[] data = new byte[]{(byte) 0xff, 0x00}; + TestUtils.parseFilterAndCompare( + ExpressionContext.of("$.blobBin1.get(type: BLOB) == X'ff00'"), + INDEX_FILTER_INPUT, Filter.equal("blobBin1", data)); + } + + @Test + void blobHexFilterReversed() { + byte[] data = new byte[]{(byte) 0xff, 0x00}; + TestUtils.parseFilterAndCompare( + ExpressionContext.of("X'ff00' == $.blobBin1.get(type: BLOB)"), + INDEX_FILTER_INPUT, Filter.equal("blobBin1", data)); + } + + @Test + void b64EqualityFilter() { + byte[] data = new byte[]{1, 2, 3}; + TestUtils.parseFilterAndCompare( + ExpressionContext.of("$.blobBin1.get(type: BLOB) == b64'AQID'"), + INDEX_FILTER_INPUT, Filter.equal("blobBin1", data)); + } + + @Test + void b64FilterReversed() { + byte[] data = new byte[]{1, 2, 3}; + TestUtils.parseFilterAndCompare( + ExpressionContext.of("b64'AQID' == $.blobBin1.get(type: BLOB)"), + INDEX_FILTER_INPUT, Filter.equal("blobBin1", data)); + } + + @Test + void blobInequalityNoFilter() { + TestUtils.parseDslExpressionAndCompare( + ExpressionContext.of("$.blobBin1.get(type: BLOB) != X'ff00'"), + null, + Exp.ne(Exp.blobBin("blobBin1"), Exp.val(new byte[]{(byte) 0xff, 0x00})), + INDEX_FILTER_INPUT); + } + + @Test + void blobOrderingNoFilter() { + TestUtils.parseDslExpressionAndCompare( + ExpressionContext.of("$.blobBin1.get(type: BLOB) > X'ff00'"), + null, + Exp.gt(Exp.blobBin("blobBin1"), Exp.val(new byte[]{(byte) 0xff, 0x00})), + INDEX_FILTER_INPUT); + } + + @Test + void hexAndB64FilterEquiv() { + byte[] data = new byte[]{1, 2, 3}; + Filter hexFilter = TestUtils.parseFilter( + ExpressionContext.of("$.blobBin1.get(type: BLOB) == X'010203'"), INDEX_FILTER_INPUT); + Filter b64Filter = TestUtils.parseFilter( + ExpressionContext.of("$.blobBin1.get(type: BLOB) == b64'AQID'"), INDEX_FILTER_INPUT); + assertThat(hexFilter).isEqualTo(b64Filter); + assertThat(hexFilter).isEqualTo(Filter.equal("blobBin1", data)); + } } diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java index ccb1dc7..bcf3368 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/PlaceholdersTests.java @@ -17,12 +17,15 @@ import com.aerospike.dsl.client.exp.MapExp; import com.aerospike.dsl.client.query.Filter; import com.aerospike.dsl.client.query.IndexType; +import com.aerospike.dsl.client.fluent.AerospikeComparator; import com.aerospike.dsl.util.TestUtils; import org.junit.jupiter.api.Test; import java.util.Base64; import java.util.Collection; import java.util.List; +import java.util.SortedMap; +import java.util.TreeMap; import static com.aerospike.dsl.util.TestUtils.NAMESPACE; import static org.assertj.core.api.Assertions.assertThat; @@ -321,4 +324,46 @@ void bothPlaceholdersEquality() { TestUtils.parseDslExpressionAndCompare(ExpressionContext.of("?0 == ?1", PlaceholderValues.of(42, 42)), null, exp); } + + // ---- BLOB Placeholder Binding ---- + + @Test + void blobPlaceholderByteArray() { + byte[] data = new byte[]{1, 2, 3}; + Exp expected = Exp.eq(Exp.blobBin("b"), Exp.val(data)); + TestUtils.parseDslExpressionAndCompare( + ExpressionContext.of("$.b.get(type: BLOB) == ?0", PlaceholderValues.of((Object) data)), + null, expected); + } + + @Test + void blobPlaceholderMapWithKey() { + byte[] key = new byte[]{(byte) 0xff}; + SortedMap map = new TreeMap<>(new AerospikeComparator()); + map.put(key, 42L); + Exp expected = Exp.eq(Exp.mapBin("mb"), Exp.val(map)); + TestUtils.parseDslExpressionAndCompare( + ExpressionContext.of("$.mb.get(type: MAP) == ?0", PlaceholderValues.of(map)), + null, expected); + } + + @Test + void blobPlaceholderListWithBlob() { + byte[] data = new byte[]{1, 2, 3}; + List list = List.of(data, "hello"); + Exp expected = Exp.eq(Exp.listBin("lb"), Exp.val(list)); + TestUtils.parseDslExpressionAndCompare( + ExpressionContext.of("$.lb.get(type: LIST) == ?0", PlaceholderValues.of(list)), + null, expected); + } + + @Test + void blobPlaceholderInBlobList() { + byte[] data1 = new byte[]{(byte) 0xaa}; + byte[] data2 = new byte[]{(byte) 0xbb}; + List list = List.of(data1, data2); + Expression actual = Exp.build(TestUtils.parseFilterExp( + ExpressionContext.of("$.b.get(type: BLOB) IN ?0", PlaceholderValues.of(list)))); + assertThat(actual).isNotNull(); + } } diff --git a/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java b/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java index 355d452..aa09edf 100644 --- a/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java +++ b/src/test/java/com/aerospike/dsl/parts/operand/OperandFactoryTests.java @@ -6,26 +6,33 @@ import java.util.HashMap; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class OperandFactoryTests { @Test void negMapWithIncomparableKeys() { + Map unsupportedKeyMap = new HashMap<>(); + unsupportedKeyMap.put(new Object(), "a"); + unsupportedKeyMap.put(new Object(), "b"); + assertThatThrownBy(() -> OperandFactory.createOperand(unsupportedKeyMap)) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("mutually comparable"); + } + + @Test + void mapWithMixedComparableKeys() { Map mixedKeyMap = new HashMap<>(); mixedKeyMap.put(1, "a"); mixedKeyMap.put("b", 2); - assertThatThrownBy(() -> OperandFactory.createOperand(mixedKeyMap)) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("mutually comparable"); + assertThat(OperandFactory.createOperand(mixedKeyMap)).isInstanceOf(MapOperand.class); } @Test - void negMapWithNullKey() { + void mapWithNullKey() { Map nullKeyMap = new HashMap<>(); nullKeyMap.put(null, "value"); - assertThatThrownBy(() -> OperandFactory.createOperand(nullKeyMap)) - .isInstanceOf(DslParseException.class) - .hasMessageContaining("mutually comparable"); + assertThat(OperandFactory.createOperand(nullKeyMap)).isInstanceOf(MapOperand.class); } } From fa976302f461e1ba2a0b41a6847b4aaaba65104a Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 3 Apr 2026 23:17:26 +0200 Subject: [PATCH 2/2] Fix the NPE in visitPathFunctionGet --- .../visitor/ExpressionConditionVisitor.java | 14 +- .../dsl/expression/BareGetFunctionTests.java | 148 ++++++++++++++++++ .../com/aerospike/dsl/util/TestUtils.java | 12 ++ 3 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/aerospike/dsl/expression/BareGetFunctionTests.java diff --git a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java index 47cbecf..21bc688 100644 --- a/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/dsl/visitor/ExpressionConditionVisitor.java @@ -778,12 +778,14 @@ public AbstractPart visitIntLogicalRShiftExpression(ConditionParser.IntLogicalRS public AbstractPart visitPathFunctionGet(ConditionParser.PathFunctionGetContext ctx) { PathFunction.ReturnParam returnParam = null; Exp.Type binType = null; - for (ConditionParser.PathFunctionParamContext paramCtx : ctx.pathFunctionParams().pathFunctionParam()) { - if (paramCtx != null) { - String typeVal = getPathFunctionParam(paramCtx, "type"); - if (typeVal != null) binType = Exp.Type.valueOf(typeVal); - String returnVal = getPathFunctionParam(paramCtx, "return"); - if (returnVal != null) returnParam = PathFunction.ReturnParam.valueOf(returnVal); + if (ctx.pathFunctionParams() != null) { + for (ConditionParser.PathFunctionParamContext paramCtx : ctx.pathFunctionParams().pathFunctionParam()) { + if (paramCtx != null) { + String typeVal = getPathFunctionParam(paramCtx, "type"); + if (typeVal != null) binType = Exp.Type.valueOf(typeVal); + String returnVal = getPathFunctionParam(paramCtx, "return"); + if (returnVal != null) returnParam = PathFunction.ReturnParam.valueOf(returnVal); + } } } return new PathFunction(PathFunction.PathFunctionType.GET, returnParam, binType); diff --git a/src/test/java/com/aerospike/dsl/expression/BareGetFunctionTests.java b/src/test/java/com/aerospike/dsl/expression/BareGetFunctionTests.java new file mode 100644 index 0000000..365818a --- /dev/null +++ b/src/test/java/com/aerospike/dsl/expression/BareGetFunctionTests.java @@ -0,0 +1,148 @@ +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.exp.Exp; +import com.aerospike.dsl.client.exp.Expression; +import com.aerospike.dsl.impl.DSLParserImpl; +import org.junit.jupiter.api.Test; + +import static com.aerospike.dsl.util.TestUtils.parseCtx; +import static com.aerospike.dsl.util.TestUtils.parseDslAndCompare; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests that bare {@code .get()} (no parameters) is an identity operation: + * appending it to any path produces the same expression as the path alone. + */ +class BareGetFunctionTests { + + private static final DSLParserImpl parser = new DSLParserImpl(); + + // --- Plain bin paths --- + + @Test + void binComparison() { + parseDslAndCompare("$.intBin1.get() != 100", "$.intBin1 != 100"); + } + + @Test + void binComparisonBothSides() { + parseDslAndCompare("$.intBin1.get() > $.intBin2.get()", "$.intBin1 > $.intBin2"); + } + + // --- List CDT paths --- + + @Test + void listByIndex() { + parseDslAndCompare("$.listBin1.[0].get() == 100", "$.listBin1.[0] == 100"); + } + + @Test + void listByValue() { + parseDslAndCompare("$.listBin1.[=100].get() == 100", "$.listBin1.[=100] == 100"); + } + + @Test + void listByRank() { + parseDslAndCompare("$.listBin1.[#-1].get() == 100", "$.listBin1.[#-1] == 100"); + } + + // --- Map CDT paths --- + + @Test + void mapByKey() { + parseDslAndCompare("$.mapBin1.a.get() == 100", "$.mapBin1.a == 100"); + } + + @Test + void mapByIndex() { + parseDslAndCompare("$.mapBin1.{0}.get() == 100", "$.mapBin1.{0} == 100"); + } + + @Test + void mapByValue() { + parseDslAndCompare("$.mapBin1.{=100}.get() == 100", "$.mapBin1.{=100} == 100"); + } + + @Test + void mapByRank() { + parseDslAndCompare("$.mapBin1.{#-1}.get() == 100", "$.mapBin1.{#-1} == 100"); + } + + // --- Nested and mixed CDT paths --- + + @Test + void nestedListIndexes() { + parseDslAndCompare( + "$.listBin1.[0].[0].[0].get() == 100", + "$.listBin1.[0].[0].[0] == 100"); + } + + @Test + void nestedMapKeys() { + parseDslAndCompare( + "$.mapBin1.a.bb.bcc.get() == 100", + "$.mapBin1.a.bb.bcc == 100"); + } + + @Test + void mapToList() { + parseDslAndCompare( + "$.mapBin1.a.cc.[2].get() > 100", + "$.mapBin1.a.cc.[2] > 100"); + } + + @Test + void listToMap() { + parseDslAndCompare( + "$.listBin1.[2].cc.get() > 100", + "$.listBin1.[2].cc > 100"); + } + + // --- Compound expressions --- + + @Test + void arithmetic() { + parseDslAndCompare( + "($.intBin1.get() + $.intBin2) > 10", + "($.intBin1 + $.intBin2) > 10"); + } + + @Test + void logical() { + parseDslAndCompare( + "$.intBin1.get() > 5 and $.intBin2.get() < 10", + "$.intBin1 > 5 and $.intBin2 < 10"); + } + + @Test + void inExpression() { + parseDslAndCompare( + "$.intBin1.get() in [1, 2, 3]", + "$.intBin1 in [1, 2, 3]"); + } + + @Test + void placeholder() { + PlaceholderValues pv = PlaceholderValues.of(42); + Expression actual = Exp.build( + parser.parseExpression(ExpressionContext.of("$.intBin1.get() > ?0", pv)).getResult().getExp()); + Expression expected = Exp.build( + parser.parseExpression(ExpressionContext.of("$.intBin1 > ?0", pv)).getResult().getExp()); + assertEquals(expected, actual); + } + + // --- parseCTX rejection (negative) --- + + @Test + void negParseCtxRejectsBareGet() { + assertThatThrownBy(() -> parseCtx("$.listBin1.[0].get()")) + .isInstanceOf(DslParseException.class) + .hasMessageContaining("Could not parse the given DSL path input") + .hasCauseInstanceOf(UnsupportedOperationException.class) + .hasStackTraceContaining("Path function is unsupported"); + } +} diff --git a/src/test/java/com/aerospike/dsl/util/TestUtils.java b/src/test/java/com/aerospike/dsl/util/TestUtils.java index ca8959f..73d5296 100644 --- a/src/test/java/com/aerospike/dsl/util/TestUtils.java +++ b/src/test/java/com/aerospike/dsl/util/TestUtils.java @@ -131,6 +131,18 @@ public static void parseDslExpressionAndCompare(ExpressionContext expressionCont assertEquals(exp == null ? null : Exp.build(exp), actualExp == null ? null : Exp.build(actualExp)); } + /** + * Parses two DSL expression strings and asserts that they produce identical packed {@link Expression} bytes. + * + * @param dslActual The DSL string whose result is being verified + * @param dslExpected The reference DSL string that defines the expected result + */ + public static void parseDslAndCompare(String dslActual, String dslExpected) { + Expression actual = Exp.build(parser.parseExpression(ExpressionContext.of(dslActual)).getResult().getExp()); + Expression expected = Exp.build(parser.parseExpression(ExpressionContext.of(dslExpected)).getResult().getExp()); + assertEquals(expected, actual); + } + /** * Parses the given DSL path String into array of {@link CTX}. *