From 971745690f6a7f8270ca488ade3c552de8d0e06e Mon Sep 17 00:00:00 2001 From: Andrey G Date: Mon, 27 Apr 2026 17:55:38 +0200 Subject: [PATCH] Support "unknown" and "error" special words, add tests --- ...07-conditional-logic-control-structures.md | 39 ++ .../antlr4/com/aerospike/ael/Condition.g4 | 23 +- .../com/aerospike/ael/parts/AbstractPart.java | 3 +- .../parts/controlstructure/AndStructure.java | 5 +- .../controlstructure/ExclusiveStructure.java | 5 +- .../parts/controlstructure/OrStructure.java | 5 +- .../ael/parts/operand/UnknownOperand.java | 20 + .../com/aerospike/ael/util/ParsingUtils.java | 13 + .../visitor/ExpressionConditionVisitor.java | 58 ++- .../aerospike/ael/visitor/VisitorUtils.java | 25 +- .../ArithmeticExpressionsTests.java | 4 +- .../ael/expression/BinNamingTests.java | 170 +++++++ .../expression/UnknownExpressionTests.java | 448 ++++++++++++++++++ 13 files changed, 764 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/aerospike/ael/parts/operand/UnknownOperand.java create mode 100644 src/test/java/com/aerospike/ael/expression/UnknownExpressionTests.java diff --git a/docs/guides/07-conditional-logic-control-structures.md b/docs/guides/07-conditional-logic-control-structures.md index 41923b7..e5bcac7 100644 --- a/docs/guides/07-conditional-logic-control-structures.md +++ b/docs/guides/07-conditional-logic-control-structures.md @@ -86,6 +86,45 @@ Expression filter = Exp.build(parsed.getResult().getExp()); The `when` structure enables you to push complex conditional logic directly to the server, reducing the need to pull data to the client for evaluation and minimizing data transfer. +## Error Handling: `unknown` and `error` + +The `unknown` keyword produces an expression that throws a server-side exception whenever it is evaluated. While primarily useful in `when` branches to signal that a particular condition should never occur, `unknown`/`error` is syntactically valid in any expression position (e.g., `$.a == unknown`, `unknown + 1`). Outside a guarded `when` branch, it will cause a server-side exception on evaluation. + +The `error` keyword is syntactic sugar for `unknown` -- both compile to the same underlying expression (`Exp.unknown()`). Use whichever reads better in your context: `error` may be clearer when the intent is to signal a failure, while `unknown` matches the underlying Aerospike Exp API name. + +### Example: Fail on Default + +Return a map bin if its size exceeds 5, otherwise throw a server-side exception: + +``` +when($.mapBin.{}.count() > 5 => $.mapBin, default => unknown) +``` + +Or equivalently with `error`: + +``` +when($.mapBin.{}.count() > 5 => $.mapBin, default => error) +``` + +### Example: Fail on Specific Branch + +You can also use `unknown`/`error` in a non-default branch: + +``` +when($.status == 0 => error, $.status == 1 => 'active', default => 'inactive') +``` + +### Using as a Bin Name + +Since `unknown` and `error` are reserved keywords, they are interpreted as expressions (not bin names) when used without the `$.` prefix. To reference bins named `unknown` or `error`, use the `$.` prefix: + +``` +$.unknown == 5 +$.error == 10 +``` + +Quoted forms are also supported and equivalent: `$.'unknown'`, `$."error"`. + ## Control Structure `let` The basic structure of a let expression allows you to declare temporary variables and use them within a subsequent expression: diff --git a/src/main/antlr4/com/aerospike/ael/Condition.g4 b/src/main/antlr4/com/aerospike/ael/Condition.g4 index fdfaa71..19daa15 100644 --- a/src/main/antlr4/com/aerospike/ael/Condition.g4 +++ b/src/main/antlr4/com/aerospike/ael/Condition.g4 @@ -94,6 +94,7 @@ operand | exclusiveExpression | letExpression | whenExpression + | unknownExpression ; notExpression: 'not' '(' expression ')'; @@ -104,6 +105,8 @@ letExpression: 'let' '(' variableDefinition (',' variableDefinition)* ')' 'then' whenExpression: 'when' '(' expressionMapping (',' expressionMapping)* ',' 'default' '=>' expression ')'; +unknownExpression: 'unknown' | 'error'; + functionCall : NAME_IDENTIFIER '(' expression (',' expression)* ')' ; @@ -233,12 +236,8 @@ PATH_FUNCTION_CDT_RETURN_TYPE | 'REVERSE_RANK' ; -binPart - : BIN_IDENTIFIER - | NAME_IDENTIFIER - | QUOTED_STRING - | IN - | TRUE +reservedWord + : TRUE | FALSE | PATH_FUNCTION_GET | PATH_FUNCTION_PARAM_TYPE @@ -258,6 +257,16 @@ binPart | 'increment' | 'clear' | 'sort' + | 'unknown' + | 'error' + ; + +binPart + : BIN_IDENTIFIER + | NAME_IDENTIFIER + | QUOTED_STRING + | IN + | reservedWord ; mapPart @@ -285,6 +294,7 @@ mapKey | INT | BLOB_LITERAL | B64_LITERAL + | reservedWord ; mapValue: '{=' valueIdentifier '}'; @@ -540,6 +550,7 @@ valueIdentifier | IN | BLOB_LITERAL | B64_LITERAL + | reservedWord ; valueListIdentifier: valueIdentifier ',' valueIdentifier (',' valueIdentifier)*; diff --git a/src/main/java/com/aerospike/ael/parts/AbstractPart.java b/src/main/java/com/aerospike/ael/parts/AbstractPart.java index dae2a9f..30ff36b 100644 --- a/src/main/java/com/aerospike/ael/parts/AbstractPart.java +++ b/src/main/java/com/aerospike/ael/parts/AbstractPart.java @@ -46,6 +46,7 @@ public enum PartType { VARIABLE_OPERAND, PLACEHOLDER_OPERAND, FUNCTION_ARGS, - BLOB_OPERAND + BLOB_OPERAND, + UNKNOWN_OPERAND } } diff --git a/src/main/java/com/aerospike/ael/parts/controlstructure/AndStructure.java b/src/main/java/com/aerospike/ael/parts/controlstructure/AndStructure.java index 8036e8b..6d84ef6 100644 --- a/src/main/java/com/aerospike/ael/parts/controlstructure/AndStructure.java +++ b/src/main/java/com/aerospike/ael/parts/controlstructure/AndStructure.java @@ -1,7 +1,6 @@ package com.aerospike.ael.parts.controlstructure; import com.aerospike.ael.parts.AbstractPart; -import com.aerospike.ael.parts.ExpressionContainer; import lombok.Getter; import java.util.List; @@ -9,9 +8,9 @@ @Getter public class AndStructure extends AbstractPart { - private final List operands; + private final List operands; - public AndStructure(List operands) { + public AndStructure(List operands) { super(PartType.AND_STRUCTURE); this.operands = operands; } diff --git a/src/main/java/com/aerospike/ael/parts/controlstructure/ExclusiveStructure.java b/src/main/java/com/aerospike/ael/parts/controlstructure/ExclusiveStructure.java index 17883c7..6cc0735 100644 --- a/src/main/java/com/aerospike/ael/parts/controlstructure/ExclusiveStructure.java +++ b/src/main/java/com/aerospike/ael/parts/controlstructure/ExclusiveStructure.java @@ -1,7 +1,6 @@ package com.aerospike.ael.parts.controlstructure; import com.aerospike.ael.parts.AbstractPart; -import com.aerospike.ael.parts.ExpressionContainer; import lombok.Getter; import java.util.List; @@ -9,9 +8,9 @@ @Getter public class ExclusiveStructure extends AbstractPart { - private final List operands; + private final List operands; - public ExclusiveStructure(List operands) { + public ExclusiveStructure(List operands) { super(PartType.EXCLUSIVE_STRUCTURE); this.operands = operands; } diff --git a/src/main/java/com/aerospike/ael/parts/controlstructure/OrStructure.java b/src/main/java/com/aerospike/ael/parts/controlstructure/OrStructure.java index 3fc3947..9fcc752 100644 --- a/src/main/java/com/aerospike/ael/parts/controlstructure/OrStructure.java +++ b/src/main/java/com/aerospike/ael/parts/controlstructure/OrStructure.java @@ -1,7 +1,6 @@ package com.aerospike.ael.parts.controlstructure; import com.aerospike.ael.parts.AbstractPart; -import com.aerospike.ael.parts.ExpressionContainer; import lombok.Getter; import java.util.List; @@ -9,9 +8,9 @@ @Getter public class OrStructure extends AbstractPart { - private final List operands; + private final List operands; - public OrStructure(List operands) { + public OrStructure(List operands) { super(PartType.OR_STRUCTURE); this.operands = operands; } diff --git a/src/main/java/com/aerospike/ael/parts/operand/UnknownOperand.java b/src/main/java/com/aerospike/ael/parts/operand/UnknownOperand.java new file mode 100644 index 0000000..64a3e3e --- /dev/null +++ b/src/main/java/com/aerospike/ael/parts/operand/UnknownOperand.java @@ -0,0 +1,20 @@ +package com.aerospike.ael.parts.operand; + +import com.aerospike.ael.client.exp.Exp; +import com.aerospike.ael.parts.AbstractPart; + +/** + * AST node for the {@code unknown} and {@code error} keywords. + * Both compile to {@link Exp#unknown()}, which throws a server-side exception on evaluation. + */ +public class UnknownOperand extends AbstractPart { + + public UnknownOperand() { + super(PartType.UNKNOWN_OPERAND); + } + + @Override + public Exp getExp() { + return Exp.unknown(); + } +} diff --git a/src/main/java/com/aerospike/ael/util/ParsingUtils.java b/src/main/java/com/aerospike/ael/util/ParsingUtils.java index 4ab13f0..8f9a649 100644 --- a/src/main/java/com/aerospike/ael/util/ParsingUtils.java +++ b/src/main/java/com/aerospike/ael/util/ParsingUtils.java @@ -75,6 +75,7 @@ private static BigInteger parseUnsignedIntegerLiteral(String text) { /** * Resolves the string content from a parser rule context that may contain * NAME_IDENTIFIER, QUOTED_STRING, or IN tokens. + * Rejects unquoted reserved words with a descriptive error. * * @param ctx Any parser rule context containing string-like tokens * @return The resolved string, or {@code null} if no matching token is found @@ -92,9 +93,21 @@ private static String resolveStringToken(ParserRuleContext ctx) { if (in != null) { return in.getText(); } + rejectUnquotedReservedWord(ctx); return null; } + private static void rejectUnquotedReservedWord(ParserRuleContext ctx) { + ConditionParser.ReservedWordContext rw = ctx.getRuleContext( + ConditionParser.ReservedWordContext.class, 0); + if (rw != null) { + String text = rw.getText(); + throw new AelParseException( + "'%s' is a reserved word and must be quoted when used as a CDT part, e.g., $..'%s' or $..\"%s\"" + .formatted(text, text, text)); + } + } + /** * Extracts a typed value from a {@code mapKey} parser rule context. * Returns {@link Long} for all INT tokens (decimal, hex, binary), diff --git a/src/main/java/com/aerospike/ael/visitor/ExpressionConditionVisitor.java b/src/main/java/com/aerospike/ael/visitor/ExpressionConditionVisitor.java index fa3dd9f..f18ea79 100644 --- a/src/main/java/com/aerospike/ael/visitor/ExpressionConditionVisitor.java +++ b/src/main/java/com/aerospike/ael/visitor/ExpressionConditionVisitor.java @@ -97,42 +97,46 @@ public AbstractPart visitAndExpression(ConditionParser.AndExpressionContext ctx) return visit(ctx.comparisonExpression(0)); } - List expressions = new ArrayList<>(); + List expressions = new ArrayList<>(); for (ConditionParser.ComparisonExpressionContext ec : ctx.comparisonExpression()) { - ExpressionContainer expr = (ExpressionContainer) visit(ec); - if (expr == null) return null; + AbstractPart part = visit(ec); + if (part == null) return null; - logicalSetBinAsBooleanExpr(expr); - expressions.add(expr); + if (part instanceof ExpressionContainer ec2) { + logicalSetBinAsBooleanExpr(ec2); + } + expressions.add(part); } return new ExpressionContainer(new AndStructure(expressions), ExpressionContainer.ExprPartsOperation.AND_STRUCTURE); } @Override public AbstractPart visitOrExpression(ConditionParser.OrExpressionContext ctx) { - // If there's only one andExpression and no 'or' operators, just pass through if (ctx.logicalAndExpression().size() == 1) { return visit(ctx.logicalAndExpression(0)); } - List expressions = new ArrayList<>(); - // iterate through each sub-expression + List expressions = new ArrayList<>(); for (ConditionParser.LogicalAndExpressionContext ec : ctx.logicalAndExpression()) { - ExpressionContainer expr = (ExpressionContainer) visit(ec); - if (expr == null) return null; + AbstractPart part = visit(ec); + if (part == null) return null; - logicalSetBinAsBooleanExpr(expr); - expressions.add(expr); + if (part instanceof ExpressionContainer ec2) { + logicalSetBinAsBooleanExpr(ec2); + } + expressions.add(part); } return new ExpressionContainer(new OrStructure(expressions), ExpressionContainer.ExprPartsOperation.OR_STRUCTURE); } @Override public AbstractPart visitNotExpression(ConditionParser.NotExpressionContext ctx) { - ExpressionContainer expr = (ExpressionContainer) visit(ctx.expression()); + AbstractPart part = visit(ctx.expression()); - logicalSetBinAsBooleanExpr(expr); - return new ExpressionContainer(expr, ExpressionContainer.ExprPartsOperation.NOT); + if (part instanceof ExpressionContainer ec) { + logicalSetBinAsBooleanExpr(ec); + } + return new ExpressionContainer(part, ExpressionContainer.ExprPartsOperation.NOT); } @Override @@ -140,12 +144,13 @@ public AbstractPart visitExclusiveExpression(ConditionParser.ExclusiveExpression if (ctx.expression().size() < 2) { throw new AelParseException("Exclusive logical operator requires 2 or more expressions"); } - List expressions = new ArrayList<>(); - // iterate through each sub-expression + List expressions = new ArrayList<>(); for (ConditionParser.ExpressionContext ec : ctx.expression()) { - ExpressionContainer expr = (ExpressionContainer) visit(ec); - logicalSetBinAsBooleanExpr(expr); - expressions.add(expr); + AbstractPart part = visit(ec); + if (part instanceof ExpressionContainer ec2) { + logicalSetBinAsBooleanExpr(ec2); + } + expressions.add(part); } return new ExpressionContainer(new ExclusiveStructure(expressions), ExpressionContainer.ExprPartsOperation.EXCLUSIVE_STRUCTURE); @@ -513,7 +518,8 @@ private static void validateInVariableIsListCompatible(ExpressionContainer expr, AbstractPart.PartType.STRING_OPERAND, AbstractPart.PartType.MAP_OPERAND, AbstractPart.PartType.METADATA_OPERAND, - AbstractPart.PartType.BLOB_OPERAND + AbstractPart.PartType.BLOB_OPERAND, + AbstractPart.PartType.UNKNOWN_OPERAND ); private static boolean isNotList(AbstractPart part) { @@ -858,8 +864,8 @@ public AbstractPart visitBinPart(ConditionParser.BinPartContext ctx) { rejectBinNameContainingNull(binName); return new BinPart(binName); } - // Fallthrough: NAME_IDENTIFIER, IN, TRUE, FALSE, and all keyword literals ('and', 'or', - // 'not', etc.). ctx.getText() returns the matched text preserving original case. + // Fallthrough: NAME_IDENTIFIER, IN, and reservedWord alternatives. + // ctx.getText() returns the matched text preserving original case. String binName = ctx.getText(); rejectBinNameContainingNull(binName); return new BinPart(binName); @@ -886,6 +892,7 @@ private static void rejectBinNameContainingNull(String binName) { } } + @Override public AbstractPart visitOperandExpression(ConditionParser.OperandExpressionContext ctx) { return visit(ctx.operand()); @@ -1024,6 +1031,11 @@ public AbstractPart visitBooleanOperand(ConditionParser.BooleanOperandContext ct return new BooleanOperand(Boolean.parseBoolean(text)); } + @Override + public AbstractPart visitUnknownExpression(ConditionParser.UnknownExpressionContext ctx) { + return new UnknownOperand(); + } + @Override public AbstractPart visitPlaceholder(ConditionParser.PlaceholderContext ctx) { // Extract index from the placeholder diff --git a/src/main/java/com/aerospike/ael/visitor/VisitorUtils.java b/src/main/java/com/aerospike/ael/visitor/VisitorUtils.java index 937fd1f..40aacd7 100644 --- a/src/main/java/com/aerospike/ael/visitor/VisitorUtils.java +++ b/src/main/java/com/aerospike/ael/visitor/VisitorUtils.java @@ -366,7 +366,7 @@ private static Exp getExpBinComparison(BinPart binPart, AbstractPart anotherPart } yield anotherPart.getExp(); } - case PATH_OPERAND, VARIABLE_OPERAND -> anotherPart.getExp(); + case PATH_OPERAND, VARIABLE_OPERAND, UNKNOWN_OPERAND -> anotherPart.getExp(); case BIN_PART -> { // Both are bin parts validateComparableTypes(binPart.getExpType(), anotherPart.getExpType()); @@ -1138,8 +1138,10 @@ private static void replacePlaceholdersInWhenStructure(AbstractPart part, Placeh */ private static void replacePlaceholdersInExclusiveStructure(AbstractPart part, PlaceholderValues placeholderValues) { ExclusiveStructure exclStructure = (ExclusiveStructure) part; - for (ExpressionContainer exprContainer : exclStructure.getOperands()) { - replacePlaceholdersInExprContainer(exprContainer, placeholderValues); + for (AbstractPart operand : exclStructure.getOperands()) { + if (operand instanceof ExpressionContainer ec) { + replacePlaceholdersInExprContainer(ec, placeholderValues); + } } } @@ -1438,9 +1440,8 @@ private static Exp whenStructureToExp(ExpressionContainer expr) { */ private static Exp exclStructureToExp(ExpressionContainer expr) { List expressions = new ArrayList<>(); - ExclusiveStructure exclOperandsList = (ExclusiveStructure) expr.getLeft(); // extract unary Expr operand - List operands = exclOperandsList.getOperands(); - for (ExpressionContainer part : operands) { + ExclusiveStructure exclOperandsList = (ExclusiveStructure) expr.getLeft(); + for (AbstractPart part : exclOperandsList.getOperands()) { expressions.add(getExp(part)); } return Exp.exclusive(expressions.toArray(new Exp[0])); @@ -1448,8 +1449,7 @@ private static Exp exclStructureToExp(ExpressionContainer expr) { private static Exp orStructureToExp(ExpressionContainer expr) { List expressions = new ArrayList<>(); - List operands = ((OrStructure) expr.getLeft()).getOperands(); - for (ExpressionContainer part : operands) { + for (AbstractPart part : ((OrStructure) expr.getLeft()).getOperands()) { expressions.add(getExp(part)); } return Exp.or(expressions.toArray(new Exp[0])); @@ -1463,17 +1463,16 @@ private static Exp orStructureToExp(ExpressionContainer expr) { */ private static Exp andStructureToExp(ExpressionContainer expr) { List expressions = new ArrayList<>(); - List operands = ((AndStructure) expr.getLeft()).getOperands(); - for (ExpressionContainer part : operands) { + for (AbstractPart part : ((AndStructure) expr.getLeft()).getOperands()) { Exp exp = getExp(part); - if (exp != null) expressions.add(exp); // Exp can be null if it is already used in secondary index + if (exp != null) expressions.add(exp); } if (expressions.isEmpty()) { return null; } else if (expressions.size() > 1) { return Exp.and(expressions.toArray(new Exp[0])); } - return expressions.get(0); // When there is only one Exp return it + return expressions.get(0); } /** @@ -2012,7 +2011,7 @@ public static void traverseTree(AbstractPart part, Consumer visito } if (part.getPartType() == AbstractPart.PartType.AND_STRUCTURE && depth > 0) { - List containerList = ((AndStructure) part).getOperands(); + List containerList = ((AndStructure) part).getOperands(); containerList.forEach(container -> traverseTree(container, visitor, depth - 1, stopCondition)); } diff --git a/src/test/java/com/aerospike/ael/expression/ArithmeticExpressionsTests.java b/src/test/java/com/aerospike/ael/expression/ArithmeticExpressionsTests.java index 132476e..8c3012b 100644 --- a/src/test/java/com/aerospike/ael/expression/ArithmeticExpressionsTests.java +++ b/src/test/java/com/aerospike/ael/expression/ArithmeticExpressionsTests.java @@ -488,8 +488,8 @@ void bitwiseInFunctionArg() { // --- Negative / error tests for functions --- @Test - void negativeUnknownFunction() { - assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("unknown($.a) == 5"))) + void negativeUndefinedFunction() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("xyz($.a) == 5"))) .isInstanceOf(AelParseException.class) .hasMessageContaining("Unknown function"); } diff --git a/src/test/java/com/aerospike/ael/expression/BinNamingTests.java b/src/test/java/com/aerospike/ael/expression/BinNamingTests.java index b9f4206..4bf5e7d 100644 --- a/src/test/java/com/aerospike/ael/expression/BinNamingTests.java +++ b/src/test/java/com/aerospike/ael/expression/BinNamingTests.java @@ -10,6 +10,8 @@ import com.aerospike.ael.client.exp.MapExp; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.List; @@ -342,6 +344,174 @@ void binNamedInCasePreserved() { } } + @Nested + class KeywordCdtPaths { + + @Test + void binWhenWithMapKey() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("key"), Exp.mapBin("when")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.when.key == 1"), expected); + } + + @Test + void binDefaultWithMapKey() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("key"), Exp.mapBin("default")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.default.key == 1"), expected); + } + + @Test + void binLetWithMapKey() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("key"), Exp.mapBin("let")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.let.key == 1"), expected); + } + + @Test + void binAndWithListIndex() { + Exp expected = Exp.eq( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.INT, + Exp.val(0), Exp.listBin("and")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.and.[0] == 1"), expected); + } + + @Test + void binOrWithListIndex() { + Exp expected = Exp.eq( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.INT, + Exp.val(0), Exp.listBin("or")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.or.[0] == 1"), expected); + } + + @Test + void binThenWithListIndex() { + Exp expected = Exp.eq( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.INT, + Exp.val(0), Exp.listBin("then")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.then.[0] == 1"), expected); + } + + @Test + void quotedKeywordMapKeyWhen() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("when"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'when' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyDefault() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("default"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'default' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyAnd() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("and"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'and' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyOr() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("or"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'or' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyLet() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("let"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'let' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyThen() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("then"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'then' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyUnknown() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("unknown"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'unknown' == 1"), expected); + } + + @Test + void quotedKeywordMapKeyError() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("error"), Exp.mapBin("mapBin")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.mapBin.'error' == 1"), expected); + } + } + + @Nested + class UnquotedKeywordCdtRestriction { + + private static final String RESERVED_WORD_MSG = "reserved word"; + private static final String MUST_BE_QUOTED_MSG = "must be quoted"; + + @ParameterizedTest + @ValueSource(strings = {"when", "default", "and", "or", "let", "then", + "unknown", "error", "true", "get"}) + void negUnquotedMapKey(String keyword) { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.mapBin.%s == 1".formatted(keyword)))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining(RESERVED_WORD_MSG) + .hasMessageContaining(MUST_BE_QUOTED_MSG); + } + + @ParameterizedTest + @ValueSource(strings = {"when", "unknown", "error"}) + void negUnquotedListValue(String keyword) { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.listBin.[=%s] == 1".formatted(keyword)))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining(RESERVED_WORD_MSG) + .hasMessageContaining(MUST_BE_QUOTED_MSG); + } + + @ParameterizedTest + @ValueSource(strings = {"unknown", "error"}) + void negUnquotedMapValue(String keyword) { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("$.mapBin.{=%s} == 1".formatted(keyword)))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining(RESERVED_WORD_MSG) + .hasMessageContaining(MUST_BE_QUOTED_MSG); + } + } + @Nested class NullRestriction { diff --git a/src/test/java/com/aerospike/ael/expression/UnknownExpressionTests.java b/src/test/java/com/aerospike/ael/expression/UnknownExpressionTests.java new file mode 100644 index 0000000..3591fdf --- /dev/null +++ b/src/test/java/com/aerospike/ael/expression/UnknownExpressionTests.java @@ -0,0 +1,448 @@ +package com.aerospike.ael.expression; + +import com.aerospike.ael.AelParseException; +import com.aerospike.ael.ExpressionContext; +import com.aerospike.ael.PlaceholderValues; +import com.aerospike.ael.client.cdt.ListReturnType; +import com.aerospike.ael.client.cdt.MapReturnType; +import com.aerospike.ael.client.exp.Exp; +import com.aerospike.ael.client.exp.ListExp; +import com.aerospike.ael.client.exp.MapExp; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static com.aerospike.ael.util.TestUtils.*; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnknownExpressionTests { + + /** + * Tests for {@code unknown} and {@code error} keywords as expressions. + * Outside a {@code when} branch, these will most likely cause a server-side exception on evaluation. + */ + @Nested + class KeywordUsage { + + @Test + void unknownAsWhenDefault() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("a"), Exp.val(5)), + Exp.intBin("a"), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.a > 5 => $.a, default => unknown)"), expected); + } + + @Test + void errorAsWhenDefault() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("a"), Exp.val(5)), + Exp.intBin("a"), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.a > 5 => $.a, default => error)"), expected); + } + + @Test + void unknownAsWhenActionBranch() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("a"), Exp.val(5)), + Exp.unknown(), + Exp.intBin("a") + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.a > 5 => unknown, default => $.a)"), expected); + } + + @Test + void unknownInMultipleBranches() { + Exp expected = Exp.cond( + Exp.eq(Exp.intBin("x"), Exp.val(1)), Exp.val(10), + Exp.eq(Exp.intBin("x"), Exp.val(2)), Exp.unknown(), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.x == 1 => 10, $.x == 2 => unknown, default => error)"), + expected); + } + + @Test + void unknownInNestedWhen() { + Exp inner = Exp.cond( + Exp.gt(Exp.intBin("b"), Exp.val(0)), Exp.intBin("b"), + Exp.unknown() + ); + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("a"), Exp.val(0)), inner, + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.a > 0 => when($.b > 0 => $.b, default => unknown), " + + "default => unknown)"), + expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownAsStandaloneExpression() { + Exp expected = Exp.unknown(); + parseFilterExpressionAndCompare(ExpressionContext.of("unknown"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void errorAsStandaloneExpression() { + Exp expected = Exp.unknown(); + parseFilterExpressionAndCompare(ExpressionContext.of("error"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInArithmetic() { + Exp expected = Exp.add(Exp.unknown(), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("unknown + 1"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void errorInArithmetic() { + Exp expected = Exp.add(Exp.unknown(), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("error + 1"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInComparison() { + Exp expected = Exp.eq(Exp.unknown(), Exp.val(5)); + parseFilterExpressionAndCompare(ExpressionContext.of("unknown == 5"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void binComparedToUnknown() { + Exp expected = Exp.eq(Exp.intBin("a"), Exp.unknown()); + parseFilterExpressionAndCompare(ExpressionContext.of("$.a == unknown"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownComparedToBin() { + Exp expected = Exp.eq(Exp.unknown(), Exp.intBin("a")); + parseFilterExpressionAndCompare(ExpressionContext.of("unknown == $.a"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void binGreaterThanError() { + Exp expected = Exp.gt(Exp.intBin("a"), Exp.unknown()); + parseFilterExpressionAndCompare(ExpressionContext.of("$.a > error"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void binPlusUnknown() { + Exp expected = Exp.add(Exp.intBin("a"), Exp.unknown()); + parseFilterExpressionAndCompare(ExpressionContext.of("$.a + unknown"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInLogicalAnd() { + Exp expected = Exp.and(Exp.unknown(), Exp.gt(Exp.intBin("a"), Exp.val(1))); + parseFilterExpressionAndCompare( + ExpressionContext.of("unknown and $.a > 1"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInLogicalOr() { + Exp expected = Exp.or(Exp.gt(Exp.intBin("a"), Exp.val(1)), Exp.unknown()); + parseFilterExpressionAndCompare( + ExpressionContext.of("$.a > 1 or unknown"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void notUnknown() { + Exp expected = Exp.not(Exp.unknown()); + parseFilterExpressionAndCompare(ExpressionContext.of("not(unknown)"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInExclusive() { + Exp expected = Exp.exclusive(Exp.unknown(), Exp.gt(Exp.intBin("a"), Exp.val(1))); + parseFilterExpressionAndCompare( + ExpressionContext.of("exclusive(unknown, $.a > 1)"), expected); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownInLetDef() { + Exp expected = Exp.let( + Exp.def("x", Exp.unknown()), + Exp.add(Exp.var("x"), Exp.val(1)) + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("let(x = unknown) then (${x} + 1)"), expected); + } + + @Test + void errorAndUnknownAreEquivalent() { + parseAelAndCompare("unknown", "error"); + } + + @Test + void equivalenceInWhenContext() { + parseAelAndCompare( + "when($.a > 0 => $.a, default => unknown)", + "when($.a > 0 => $.a, default => error)"); + } + + @Test + void spacingAroundArrowWithUnknown() { + String canonical = "when($.a > 5 => $.a, default => unknown)"; + parseAelAndCompare("when($.a>5=>$.a,default=>unknown)", canonical); + parseAelAndCompare("when( $.a > 5 => $.a , default => unknown )", canonical); + } + + @Test + void spacingAroundArrowWithError() { + String canonical = "when($.a > 5 => $.a, default => error)"; + parseAelAndCompare("when($.a>5=>$.a,default=>error)", canonical); + parseAelAndCompare("when( $.a > 5 => $.a , default => error )", canonical); + } + } + + @Nested + class BinNameVariations { + + @Test + void binNamedUnknownUnquoted() { + Exp expected = Exp.eq(Exp.intBin("unknown"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.unknown == 1"), expected); + } + + @Test + void binNamedUnknownSingleQuoted() { + Exp expected = Exp.eq(Exp.intBin("unknown"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.'unknown' == 1"), expected); + } + + @Test + void binNamedUnknownDoubleQuoted() { + Exp expected = Exp.eq(Exp.intBin("unknown"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.\"unknown\" == 1"), expected); + } + + @Test + void binNamedUnknownQuotingEquiv() { + parseAelAndCompare("$.unknown == 1", "$.'unknown' == 1"); + parseAelAndCompare("$.unknown == 1", "$.\"unknown\" == 1"); + } + + @Test + void binNamedErrorUnquoted() { + Exp expected = Exp.eq(Exp.intBin("error"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.error == 1"), expected); + } + + @Test + void binNamedErrorSingleQuoted() { + Exp expected = Exp.eq(Exp.intBin("error"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.'error' == 1"), expected); + } + + @Test + void binNamedErrorDoubleQuoted() { + Exp expected = Exp.eq(Exp.intBin("error"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.\"error\" == 1"), expected); + } + + @Test + void binNamedErrorQuotingEquiv() { + parseAelAndCompare("$.error == 1", "$.'error' == 1"); + parseAelAndCompare("$.error == 1", "$.\"error\" == 1"); + } + + @Test + void binNameContainingUnknownPrefix() { + Exp expected = Exp.eq(Exp.intBin("unknown_flag"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.unknown_flag == 1"), expected); + } + + @Test + void binNameContainingErrorPrefix() { + Exp expected = Exp.eq(Exp.intBin("error_code"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.error_code == 1"), expected); + } + + @Test + void binNameContainingUnknownSuffix() { + Exp expected = Exp.eq(Exp.intBin("is_unknown"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.is_unknown == 1"), expected); + } + + @Test + void binNameContainingErrorSuffix() { + Exp expected = Exp.eq(Exp.intBin("on_error"), Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.on_error == 1"), expected); + } + } + + @Nested + class CdtPaths { + + @Test + void binUnknownWithMapKey() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("key"), Exp.mapBin("unknown")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.unknown.key == 1"), expected); + } + + @Test + void binErrorWithMapKey() { + Exp expected = Exp.eq( + MapExp.getByKey(MapReturnType.VALUE, Exp.Type.INT, + Exp.val("key"), Exp.mapBin("error")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.error.key == 1"), expected); + } + + @Test + void binUnknownWithListIndex() { + Exp expected = Exp.eq( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.INT, + Exp.val(0), Exp.listBin("unknown")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.unknown.[0] == 1"), expected); + } + + @Test + void binErrorWithListIndex() { + Exp expected = Exp.eq( + ListExp.getByIndex(ListReturnType.VALUE, Exp.Type.INT, + Exp.val(0), Exp.listBin("error")), + Exp.val(1)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.error.[0] == 1"), expected); + } + + @Test + void binUnknownWithMapWildcardCount() { + Exp expected = Exp.gt(MapExp.size(Exp.mapBin("unknown")), Exp.val(0)); + parseFilterExpressionAndCompare(ExpressionContext.of("$.unknown.{}.count() > 0"), expected); + } + } + + @Nested + class Coexistence { + + @Test + void unknownBinAndKeywordInSameExpr() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("unknown"), Exp.val(5)), + Exp.intBin("unknown"), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.unknown > 5 => $.unknown, default => unknown)"), + expected); + } + + @Test + void errorBinAndKeywordInSameExpr() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("error"), Exp.val(5)), + Exp.intBin("error"), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.error > 5 => $.error, default => error)"), + expected); + } + } + + @Nested + class Negative { + + @Test + void unknownWithParentheses() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("unknown($.a) == 5"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("mismatched input"); + } + + @Test + void errorWithParentheses() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("error($.a) == 5"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("mismatched input"); + } + + @Test + void uppercaseUnknownIsNotKeyword() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("UNKNOWN == 5"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("no viable alternative"); + } + + @Test + void mixedCaseErrorIsNotKeyword() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("Error == 5"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("no viable alternative"); + } + + @Test + void unknownAsInRightOperand() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.a in unknown"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("IN operation requires a List"); + } + + @Test + void errorAsInRightOperand() { + assertThatThrownBy(() -> parseFilterExp(ExpressionContext.of("$.a in error"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("IN operation requires a List"); + } + + @Test + void letUnknownVarUsedInIn() { + assertThatThrownBy(() -> parseFilterExp( + ExpressionContext.of("let(x = unknown) then ($.a.get(type: INT) in ${x})"))) + .isInstanceOf(AelParseException.class) + .hasMessageContaining("non-List type"); + } + + // Parses correctly but will most likely cause a server-side exception on evaluation + @Test + void unknownAsInLeftOperand() { + Exp expected = ListExp.getByValue(ListReturnType.EXISTS, + Exp.unknown(), Exp.listBin("list")); + parseFilterExpressionAndCompare( + ExpressionContext.of("unknown in $.list"), expected); + } + } + + @Nested + class PlaceholderIntegration { + + @Test + void unknownWithPlaceholderInWhen() { + Exp expected = Exp.cond( + Exp.gt(Exp.intBin("a"), Exp.val(10)), + Exp.intBin("a"), + Exp.unknown() + ); + parseFilterExpressionAndCompare( + ExpressionContext.of("when($.a > ?0 => $.a, default => unknown)", + PlaceholderValues.of(10)), + expected); + } + } +}