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..21bc688 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) {
@@ -776,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);
@@ -892,7 +896,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 +904,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 +929,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 $.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/expression/BlobTests.java b/src/test/java/com/aerospike/dsl/expression/BlobTests.java
new file mode 100644
index 0000000..4359821
--- /dev/null
+++ b/src/test/java/com/aerospike/dsl/expression/BlobTests.java
@@ -0,0 +1,536 @@
+package com.aerospike.dsl.expression;
+
+import com.aerospike.dsl.DslParseException;
+import com.aerospike.dsl.ExpressionContext;
+import com.aerospike.dsl.client.cdt.ListReturnType;
+import com.aerospike.dsl.client.exp.Exp;
+import com.aerospike.dsl.client.exp.Expression;
+import com.aerospike.dsl.client.exp.ListExp;
+import com.aerospike.dsl.client.fluent.AerospikeComparator;
+import com.aerospike.dsl.util.TestUtils;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import static com.aerospike.dsl.util.TestUtils.parseFilterExp;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class BlobTests {
+
+ // ---- Hex BLOB Literal Parsing ----
+
+ @Test
+ void blobHexUpperCase() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == X'ff00'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{(byte) 0xff, 0x00})));
+ }
+
+ @Test
+ void blobHexLowerCase() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == x'ff00'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{(byte) 0xff, 0x00})));
+ }
+
+ @Test
+ void blobHexMixedCase() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == X'aAbBcCdDeEfF'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{
+ (byte) 0xaa, (byte) 0xbb, (byte) 0xcc,
+ (byte) 0xdd, (byte) 0xee, (byte) 0xff})));
+ }
+
+ @Test
+ void blobHexEmpty() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == X''"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[0])));
+ }
+
+ @Test
+ void blobHexLong() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == x'102030405060708090abcdef'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{
+ 0x10, 0x20, 0x30, 0x40, 0x50, 0x60,
+ 0x70, (byte) 0x80, (byte) 0x90, (byte) 0xab, (byte) 0xcd, (byte) 0xef})));
+ }
+
+ // ---- Base64 BLOB Literal Parsing ----
+
+ @Test
+ void b64UpperCase() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == B64'AQID'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{1, 2, 3})));
+ }
+
+ @Test
+ void b64LowerCase() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == b64'AQID'"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{1, 2, 3})));
+ }
+
+ @Test
+ void b64WithPadding() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == b64'AQ=='"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[]{1})));
+ }
+
+ @Test
+ void b64Empty() {
+ TestUtils.parseFilterExpressionAndCompare(
+ ExpressionContext.of("$.b.get(type: BLOB) == b64''"),
+ Exp.eq(Exp.blobBin("b"), Exp.val(new byte[0])));
+ }
+
+ // ---- Hex and Base64 Equivalence ----
+
+ @Test
+ void hexAndB64Equivalence() {
+ Expression hexExp = Exp.build(parseFilterExp(
+ ExpressionContext.of("$.b.get(type: BLOB) == X'010203'")));
+ Expression b64Exp = Exp.build(parseFilterExp(
+ ExpressionContext.of("$.b.get(type: BLOB) == b64'AQID'")));
+ assertThat(hexExp).isEqualTo(b64Exp);
+ }
+
+ // ---- Negative Parsing ----
+
+ @Test
+ void negativeOddHexLength() {
+ assertThatThrownBy(() -> 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);
}
}
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}.
*