This is an automated email from the ASF dual-hosted git repository.
virajjasani pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/phoenix-adapters.git
The following commit(s) were added to refs/heads/main by this push:
new ba5b650 ANTLR grammar for Update Expressions
ba5b650 is described below
commit ba5b65085de4e59ad2d48796e0203061114f2b07
Author: Palash Chauhan <[email protected]>
AuthorDate: Tue May 26 16:12:35 2026 -0700
ANTLR grammar for Update Expressions
Generated-by: Claude Opus 4.6 <[email protected]>
---
phoenix-ddb-rest/pom.xml | 4 +
.../ddb/UpdateExpressionConversionTest.java | 621 ++++++++++++++++++++-
.../phoenix/ddb/UpdateExpressionValidationIT.java | 617 ++++++++++++++++++++
.../java/org/apache/phoenix/ddb/UpdateItemIT.java | 249 +++++++++
phoenix-ddb-utils/pom.xml | 26 +
.../apache/phoenix/ddb/update/UpdateExpression.g4 | 106 ++++
.../ddb/bson/UpdateExpressionDdbToBson.java | 282 ----------
.../phoenix/ddb/update/BsonEmittingVisitor.java | 309 ++++++++++
.../apache/phoenix/ddb/update/PathResolver.java | 81 +++
.../update/UpdateExpressionSyntaxException.java | 30 +
.../phoenix/ddb/update/UpdateExpressionToBson.java | 78 +++
.../phoenix/ddb/utils/CommonServiceUtils.java | 10 +-
pom.xml | 6 +
13 files changed, 2103 insertions(+), 316 deletions(-)
diff --git a/phoenix-ddb-rest/pom.xml b/phoenix-ddb-rest/pom.xml
index 0becc29..f1707af 100644
--- a/phoenix-ddb-rest/pom.xml
+++ b/phoenix-ddb-rest/pom.xml
@@ -74,6 +74,10 @@
<groupId>software.amazon.awssdk</groupId>
<artifactId>*</artifactId>
</exclusion>
+ <exclusion>
+ <groupId>org.antlr</groupId>
+ <artifactId>antlr4-runtime</artifactId>
+ </exclusion>
</exclusions>
</dependency>
diff --git
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionConversionTest.java
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionConversionTest.java
index a5c73af..905e840 100644
---
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionConversionTest.java
+++
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionConversionTest.java
@@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import org.bson.BsonArray;
import org.bson.BsonDocument;
import org.bson.RawBsonDocument;
import org.junit.Assert;
@@ -30,7 +31,7 @@ import
software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.phoenix.ddb.bson.DdbAttributesToBsonDocument;
-import org.apache.phoenix.ddb.bson.UpdateExpressionDdbToBson;
+import org.apache.phoenix.ddb.update.UpdateExpressionToBson;
public class UpdateExpressionConversionTest {
@@ -174,7 +175,7 @@ public class UpdateExpressionConversionTest {
Assert.assertEquals(RawBsonDocument.parse(expectedBsonUpdateExpression),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getComparisonValuesMap()));
}
@@ -197,7 +198,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expectedBsonUpdateExpression),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getComparisonValuesMap()));
}
@@ -243,7 +244,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expectedBsonUpdateExpression),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getComparisonValuesMap()));
}
@@ -261,7 +262,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -279,7 +280,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -297,7 +298,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -323,7 +324,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -355,7 +356,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -376,7 +377,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -402,7 +403,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -444,7 +445,7 @@ public class UpdateExpressionConversionTest {
BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
values));
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
}
/**
@@ -468,7 +469,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap()));
}
@@ -495,7 +496,7 @@ public class UpdateExpressionConversionTest {
attributeMap.put(":one", AttributeValue.builder().n("1").build());
BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
values));
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
}
/**
@@ -516,7 +517,7 @@ public class UpdateExpressionConversionTest {
" }\n" +
"}";
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
new BsonDocument()));
}
@@ -541,7 +542,7 @@ public class UpdateExpressionConversionTest {
attributeMap.put(":limit", AttributeValue.builder().n("100").build());
BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
values));
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
}
/**
@@ -593,19 +594,19 @@ public class UpdateExpressionConversionTest {
attributeMap.put(":nestedVal", AttributeValue.builder().s("deep").build());
BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
Assert.assertEquals(RawBsonDocument.parse(expected),
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
values));
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
}
@Test
public void testListAppendWrongArityOne() {
String ddbUpdateExp = "SET myList = list_append(:listVal)";
try {
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap());
- Assert.fail("Expected RuntimeException for wrong arity (1 operand)");
- } catch (RuntimeException e) {
+ Assert.fail("Expected IllegalArgumentException for wrong arity (1
operand)");
+ } catch (IllegalArgumentException e) {
Assert.assertTrue("Unexpected message: " + e.getMessage(),
- e.getMessage().contains("list_append requires exactly 2
operands"));
+ e.getMessage().contains("Invalid UpdateExpression"));
}
}
@@ -613,12 +614,12 @@ public class UpdateExpressionConversionTest {
public void testListAppendWrongArityThree() {
String ddbUpdateExp = "SET myList = list_append(:listVal, :listVal2,
myList)";
try {
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap());
- Assert.fail("Expected RuntimeException for wrong arity (3 operands)");
- } catch (RuntimeException e) {
+ Assert.fail("Expected IllegalArgumentException for wrong arity (3
operands)");
+ } catch (IllegalArgumentException e) {
Assert.assertTrue("Unexpected message: " + e.getMessage(),
- e.getMessage().contains("list_append requires exactly 2
operands"));
+ e.getMessage().contains("Invalid UpdateExpression"));
}
}
@@ -626,7 +627,7 @@ public class UpdateExpressionConversionTest {
public void testListAppendMissingPlaceholder() {
String ddbUpdateExp = "SET myList = list_append(myList, :missing)";
try {
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap());
Assert.fail("Expected RuntimeException for missing placeholder");
} catch (RuntimeException e) {
@@ -639,7 +640,7 @@ public class UpdateExpressionConversionTest {
public void testListAppendNonArrayPlaceholder() {
String ddbUpdateExp = "SET myList = list_append(myList, :notList)";
try {
-
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
getListAppendComparisonValuesMap());
Assert.fail("Expected RuntimeException for non-list placeholder");
} catch (RuntimeException e) {
@@ -648,6 +649,572 @@ public class UpdateExpressionConversionTest {
}
}
+ /**
+ * Tab and mixed whitespace between clause keywords.
+ */
+ @Test
+ public void testWhitespaceTabBetweenClauses() {
+ String ddbUpdateExp = "SET a = :v\tREMOVE b\tADD c :n\tDELETE d :s";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("hello").build());
+ attributeMap.put(":n", AttributeValue.builder().n("1").build());
+ attributeMap.put(":s", AttributeValue.builder().ss("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.assertTrue(actual.containsKey("$SET"));
+ Assert.assertTrue(actual.containsKey("$UNSET"));
+ Assert.assertTrue(actual.containsKey("$ADD"));
+ Assert.assertTrue(actual.containsKey("$DELETE_FROM_SET"));
+ Assert.assertEquals("hello",
actual.getDocument("$SET").getString("a").getValue());
+ Assert.assertTrue(actual.getDocument("$UNSET").containsKey("b"));
+ }
+
+ /**
+ * Arithmetic with both operands being if_not_exists.
+ */
+ @Test
+ public void testArithmeticBothOperandsIfNotExists() {
+ String ddbUpdateExp = "SET total = if_not_exists(a, :z) + if_not_exists(b,
:z)";
+ String expected = "{\n" +
+ " \"$SET\": {\n" +
+ " \"total\": {\n" +
+ " \"$ADD\": [\n" +
+ " { \"$IF_NOT_EXISTS\": { \"a\": 0 } },\n" +
+ " { \"$IF_NOT_EXISTS\": { \"b\": 0 } }\n" +
+ " ]\n" +
+ " }\n" +
+ " }\n" +
+ "}";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":z", AttributeValue.builder().n("0").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Assert.assertEquals(RawBsonDocument.parse(expected),
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
+ }
+
+ /**
+ * Arithmetic where the placeholder is the first operand and if_not_exists
is the second.
+ * Mirror of the existing testArithmeticWithIfNotExistsCounterIncrement.
+ */
+ @Test
+ public void testArithmeticLiteralPlusIfNotExists() {
+ String ddbUpdateExp = "SET counter = :one + if_not_exists(counter, :zero)";
+ String expected = "{\n" +
+ " \"$SET\": {\n" +
+ " \"counter\": {\n" +
+ " \"$ADD\": [\n" +
+ " 1,\n" +
+ " { \"$IF_NOT_EXISTS\": { \"counter\": 0 } }\n" +
+ " ]\n" +
+ " }\n" +
+ " }\n" +
+ "}";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":zero", AttributeValue.builder().n("0").build());
+ attributeMap.put(":one", AttributeValue.builder().n("1").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Assert.assertEquals(RawBsonDocument.parse(expected),
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
+ }
+
+ /**
+ * Subtract with if_not_exists on the LHS and a placeholder on the RHS.
+ */
+ @Test
+ public void testArithmeticIfNotExistsMinusLiteral() {
+ String ddbUpdateExp = "SET remaining = if_not_exists(used, :zero) - :n";
+ String expected = "{\n" +
+ " \"$SET\": {\n" +
+ " \"remaining\": {\n" +
+ " \"$SUBTRACT\": [\n" +
+ " { \"$IF_NOT_EXISTS\": { \"used\": 0 } },\n" +
+ " 5\n" +
+ " ]\n" +
+ " }\n" +
+ " }\n" +
+ "}";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":zero", AttributeValue.builder().n("0").build());
+ attributeMap.put(":n", AttributeValue.builder().n("5").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Assert.assertEquals(RawBsonDocument.parse(expected),
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values));
+ }
+
+ /**
+ * if_not_exists with one argument should be rejected by the grammar.
+ */
+ @Test
+ public void testIfNotExistsWrongArityOne() {
+ String ddbUpdateExp = "SET a = if_not_exists(a)";
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp,
+ new BsonDocument());
+ Assert.fail("Expected IllegalArgumentException for if_not_exists arity
1");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("Invalid UpdateExpression"));
+ }
+ }
+
+ /**
+ * if_not_exists with three arguments should be rejected by the grammar.
+ */
+ @Test
+ public void testIfNotExistsWrongArityThree() {
+ String ddbUpdateExp = "SET a = if_not_exists(a, :v, :v2)";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ attributeMap.put(":v2", AttributeValue.builder().s("y").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for if_not_exists arity
3");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("Invalid UpdateExpression"));
+ }
+ }
+
+ /**
+ * ADD/DELETE clauses tolerate extra inter-token whitespace (multiple
spaces, tabs).
+ */
+ @Test
+ public void testAddDeleteExtraWhitespace() {
+ String ddbUpdateExp = "ADD a :v, b\t:w DELETE c\t :s";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().n("1").build());
+ attributeMap.put(":w", AttributeValue.builder().n("2").build());
+ attributeMap.put(":s", AttributeValue.builder().ss("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ BsonDocument addDoc = actual.getDocument("$ADD");
+ Assert.assertEquals(2, addDoc.size());
+ Assert.assertTrue(addDoc.containsKey("a"));
+ Assert.assertTrue(addDoc.containsKey("b"));
+ Assert.assertTrue(actual.getDocument("$DELETE_FROM_SET").containsKey("c"));
+ }
+
+ /**
+ * Attribute names that contain a clause keyword as a substring (ADDRESS,
REMOTE, SETUP)
+ * should not confuse the clause-splitting regex. Word-boundary anchored.
+ */
+ @Test
+ public void testAttributeNameContainingKeywordSubstring() {
+ String ddbUpdateExp = "SET ADDRESS = :v, REMOTE = :v2 ADD SETUP :n";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("home").build());
+ attributeMap.put(":v2", AttributeValue.builder().s("ssh").build());
+ attributeMap.put(":n", AttributeValue.builder().n("1").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ BsonDocument setDoc = actual.getDocument("$SET");
+ Assert.assertEquals("home", setDoc.getString("ADDRESS").getValue());
+ Assert.assertEquals("ssh", setDoc.getString("REMOTE").getValue());
+ Assert.assertEquals(1,
actual.getDocument("$ADD").getInt32("SETUP").getValue());
+ }
+
+ /**
+ * All four clauses in canonical AWS order (SET REMOVE ADD DELETE) should
each populate
+ * their respective BSON section.
+ */
+ @Test
+ public void testAllFourClausesCanonicalOrder() {
+ String ddbUpdateExp = "SET a = :v REMOVE b ADD c :n DELETE d :s";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ attributeMap.put(":n", AttributeValue.builder().n("1").build());
+ attributeMap.put(":s", AttributeValue.builder().ss("y").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.assertEquals(4, actual.size());
+ Assert.assertTrue(actual.containsKey("$SET"));
+ Assert.assertTrue(actual.containsKey("$UNSET"));
+ Assert.assertTrue(actual.containsKey("$ADD"));
+ Assert.assertTrue(actual.containsKey("$DELETE_FROM_SET"));
+ }
+
+ /**
+ * Path overlap: parent path in one clause vs nested-field path in another.
DDB rejects.
+ */
+ @Test
+ public void testPathOverlapParentAndNestedField() {
+ String ddbUpdateExp = "SET a.b = :v REMOVE a";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for parent/child path
overlap");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("paths overlap"));
+ }
+ }
+
+ /**
+ * Path overlap: parent path vs nested-array-index path.
+ */
+ @Test
+ public void testPathOverlapParentAndArrayIndex() {
+ String ddbUpdateExp = "SET a[0] = :v REMOVE a";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for parent/array-index
path overlap");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("paths overlap"));
+ }
+ }
+
+ /**
+ * Two paths sharing only a string-prefix (no separator boundary) must NOT
be flagged
+ * as overlapping. {@code a} and {@code ab} are independent attribute names.
+ */
+ @Test
+ public void testPathPrefixWithoutSeparatorDoesNotOverlap() {
+ String ddbUpdateExp = "SET a = :v, ab = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.assertEquals(2, actual.getDocument("$SET").size());
+ }
+
+ /**
+ * Sibling paths under the same parent must NOT be flagged as overlapping.
+ */
+ @Test
+ public void testPathSiblingsDoNotOverlap() {
+ String ddbUpdateExp = "SET a.b = :v, a.c = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.assertEquals(2, actual.getDocument("$SET").size());
+ }
+
+ /**
+ * Duplicate clause keyword is detected with a clear message (not the
misleading
+ * "does not include key value pairs separated by =" that the {@code
=}-split would emit).
+ */
+ @Test
+ public void testDuplicateClauseKeywordParserMessage() {
+ String ddbUpdateExp = "SET a = :v SET b = :v2";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ attributeMap.put(":v2", AttributeValue.builder().s("y").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for duplicate SET
keyword");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("appears more than once"));
+ }
+ }
+
+ /**
+ * Undeclared {@code :placeholder} surfaces a clear parser-level error
rather than an NPE.
+ */
+ @Test
+ public void testUndeclaredPlaceholderInSet() {
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(
+ "SET a = :missing", new BsonDocument());
+ Assert.fail("Expected IllegalArgumentException for undeclared
placeholder");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains(":missing")
+ && e.getMessage().contains("not declared"));
+ }
+ }
+
+ /**
+ * Alias path inside REMOVE: {@code REMOVE #a.b}.
+ */
+ @Test
+ public void testAliasPathInRemove() {
+ String ddbUpdateExp = "REMOVE #a.b";
+ Map<String, String> aliases = Collections.singletonMap("#a", "topMap");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, new BsonDocument(), aliases);
+ Assert.assertTrue(actual.getDocument("$UNSET").containsKey("topMap.b"));
+ }
+
+ /**
+ * Alias path in ADD: {@code ADD #c :v}.
+ */
+ @Test
+ public void testAliasPathInAdd() {
+ String ddbUpdateExp = "ADD #c :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().n("3").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#c", "counter");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ Assert.assertEquals(3,
actual.getDocument("$ADD").getInt32("counter").getValue());
+ }
+
+ /**
+ * Alias path in DELETE: {@code DELETE #s :v}.
+ */
+ @Test
+ public void testAliasPathInDelete() {
+ String ddbUpdateExp = "DELETE #s :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().ss("a").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#s",
"myStringSet");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+
Assert.assertTrue(actual.getDocument("$DELETE_FROM_SET").containsKey("myStringSet"));
+ }
+
+ /**
+ * All four clause keywords are case-insensitive (matches DDB). One
representative case-mix per
+ * clause; each clause maps to its own lexer fragment so a future regression
in any single
+ * keyword would surface here.
+ */
+ @Test
+ public void testClauseKeywordsAreCaseInsensitive() {
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ attributeMap.put(":n", AttributeValue.builder().n("1").build());
+ attributeMap.put(":s", AttributeValue.builder().ss("y").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+
+ Map<String, String> setForms = new HashMap<>();
+ setForms.put("set a = :v", "$SET");
+ setForms.put("Set a = :v", "$SET");
+ setForms.put("sEt a = :v", "$SET");
+ setForms.put("remove a", "$UNSET");
+ setForms.put("Remove a", "$UNSET");
+ setForms.put("REMOVE a", "$UNSET");
+ setForms.put("add a :n", "$ADD");
+ setForms.put("Add a :n", "$ADD");
+ setForms.put("aDd a :n", "$ADD");
+ setForms.put("delete a :s", "$DELETE_FROM_SET");
+ setForms.put("Delete a :s", "$DELETE_FROM_SET");
+ setForms.put("dELETE a :s", "$DELETE_FROM_SET");
+
+ for (Map.Entry<String, String> e : setForms.entrySet()) {
+ BsonDocument actual =
UpdateExpressionToBson.toBsonUpdateDocument(e.getKey(), values);
+ Assert.assertTrue("expr=" + e.getKey() + " expected key " + e.getValue(),
+ actual.containsKey(e.getValue()));
+ Assert.assertTrue("expr=" + e.getKey() + " expected attribute 'a'",
+ actual.getDocument(e.getValue()).containsKey("a"));
+ }
+ }
+
+ /**
+ * Function names ({@code if_not_exists}, {@code list_append}) are
case-sensitive per AWS docs.
+ * Mixed-case must be rejected by the lexer.
+ */
+ @Test
+ public void testFunctionNamesAreCaseSensitive() {
+ String[] mixedCaseForms = {
+ "SET a = If_Not_Exists(a, :v)",
+ "SET a = IF_NOT_EXISTS(a, :v)",
+ "SET a = list_APPEND(a, :v)",
+ "SET a = LIST_APPEND(a, :v)",
+ };
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ for (String expr : mixedCaseForms) {
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(expr, values);
+ Assert.fail("Expected IllegalArgumentException for case-mixed function
in: " + expr);
+ } catch (IllegalArgumentException e) {
+ // expected
+ }
+ }
+ }
+
+ /**
+ * {@code list_append} is not a valid arithmetic operand: lists cannot be
added/subtracted.
+ * Visitor rejects at the parser layer.
+ */
+ @Test
+ public void testListAppendAsArithmeticOperandRejected() {
+ String ddbUpdateExp = "SET col = list_append(a, :v) + list_append(:w, b)";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().l(
+ AttributeValue.builder().s("x").build()).build());
+ attributeMap.put(":w", AttributeValue.builder().l(
+ AttributeValue.builder().s("y").build()).build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for list_append in
arithmetic operand");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains("list_append")
+ && e.getMessage().contains("arithmetic"));
+ }
+ }
+
+ /**
+ * Arithmetic with an {@code if_not_exists} fallback that resolves to a
non-numeric
+ * placeholder must be rejected at the parser layer (not silently emitted to
the BSON
+ * evaluator).
+ */
+ @Test
+ public void testArithmeticIfNotExistsNonNumericFallback() {
+ String ddbUpdateExp = "SET counter = if_not_exists(c, :s) + :one";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":s", AttributeValue.builder().s("notANumber").build());
+ attributeMap.put(":one", AttributeValue.builder().n("1").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ try {
+ UpdateExpressionToBson.toBsonUpdateDocument(ddbUpdateExp, values);
+ Assert.fail("Expected IllegalArgumentException for non-numeric
if_not_exists fallback");
+ } catch (IllegalArgumentException e) {
+ Assert.assertTrue("Unexpected: " + e.getMessage(),
+ e.getMessage().contains(":s") &&
e.getMessage().contains("number"));
+ }
+ }
+
+ /**
+ * Alias-as-keyword: an ExpressionAttributeName whose VALUE happens to be a
clause keyword
+ * (e.g. {@code "ADD"}) must NOT confuse the parser. The lexer never sees
the resolved name.
+ */
+ @Test
+ public void testAliasResolvingToClauseKeyword() {
+ String ddbUpdateExp = "SET #attr = :v REMOVE other";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("hello").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#attr", "ADD");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ BsonDocument setDoc = actual.getDocument("$SET");
+ Assert.assertEquals(1, setDoc.size());
+ Assert.assertEquals("hello", setDoc.getString("ADD").getValue());
+ Assert.assertTrue(actual.getDocument("$UNSET").containsKey("other"));
+ }
+
+ /**
+ * Alias followed by a bare attribute name in a nested path: {@code
#a.bareField}.
+ */
+ @Test
+ public void testAliasThenBareNamePath() {
+ String ddbUpdateExp = "SET #a.bareField = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#a", "topMap");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ Assert.assertEquals("x",
+
actual.getDocument("$SET").getString("topMap.bareField").getValue());
+ }
+
+ /**
+ * Bare attribute name followed by an alias: {@code bareField.#a}.
+ */
+ @Test
+ public void testBareNameThenAliasPath() {
+ String ddbUpdateExp = "SET bareField.#a = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#a", "deep");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ Assert.assertEquals("x",
+ actual.getDocument("$SET").getString("bareField.deep").getValue());
+ }
+
+ /**
+ * Alias with array index immediately followed by another alias: {@code
#l[0].#nested}.
+ */
+ @Test
+ public void testAliasArrayIndexThenAliasPath() {
+ String ddbUpdateExp = "SET #l[0].#nested = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("x").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#l", "myList");
+ aliases.put("#nested", "deepField");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ Assert.assertEquals("x",
+
actual.getDocument("$SET").getString("myList[0].deepField").getValue());
+ }
+
+ /**
+ * Alias path used inside {@code if_not_exists}: {@code if_not_exists(#a.b,
:v)}.
+ */
+ @Test
+ public void testAliasPathInsideIfNotExists() {
+ String ddbUpdateExp = "SET col = if_not_exists(#a.b, :v)";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("fallback").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#a", "topMap");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ BsonDocument inner = actual.getDocument("$SET")
+ .getDocument("col").getDocument("$IF_NOT_EXISTS");
+ Assert.assertEquals("fallback", inner.getString("topMap.b").getValue());
+ }
+
+ /**
+ * Alias inside the {@code if_not_exists} that lives inside {@code
list_append}:
+ * {@code list_append(if_not_exists(#l, :empty), :items)}.
+ */
+ @Test
+ public void testAliasInsideIfNotExistsInsideListAppend() {
+ String ddbUpdateExp = "SET col = list_append(if_not_exists(#l, :empty),
:items)";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":empty",
+ AttributeValue.builder().l(Collections.emptyList()).build());
+ attributeMap.put(":items", AttributeValue.builder().l(
+ AttributeValue.builder().s("a").build()).build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = Collections.singletonMap("#l", "events");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ BsonArray operands = actual.getDocument("$SET")
+ .getDocument("col").getArray("$LIST_APPEND");
+ BsonDocument inner =
operands.get(0).asDocument().getDocument("$IF_NOT_EXISTS");
+ Assert.assertTrue("expected fallback under resolved alias key 'events'",
+ inner.containsKey("events"));
+ }
+
+ /**
+ * Multi-step path with aliases mixed with bare names and an array index,
e.g.
+ * {@code #pr.#5star[1].name}.
+ */
+ @Test
+ public void testNestedAliasPath() {
+ String ddbUpdateExp = "SET #pr.#5star[1].name = :v";
+ Map<String, AttributeValue> attributeMap = new HashMap<>();
+ attributeMap.put(":v", AttributeValue.builder().s("Alice").build());
+ BsonDocument values =
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#pr", "ProductReviews");
+ aliases.put("#5star", "FiveStar");
+ BsonDocument actual = UpdateExpressionToBson
+ .toBsonUpdateDocument(ddbUpdateExp, values, aliases);
+ Assert.assertEquals("Alice",
+
actual.getDocument("$SET").getString("ProductReviews.FiveStar[1].name").getValue());
+ }
+
private static BsonDocument getListAppendComparisonValuesMap() {
Map<String, AttributeValue> attributeMap = new HashMap<>();
attributeMap.put(":listVal", AttributeValue.builder().l(
diff --git
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionValidationIT.java
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionValidationIT.java
index c3752e1..d6f8b1c 100644
---
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionValidationIT.java
+++
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateExpressionValidationIT.java
@@ -18,7 +18,10 @@
package org.apache.phoenix.ddb;
import java.sql.DriverManager;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.junit.After;
@@ -759,4 +762,618 @@ public class UpdateExpressionValidationIT {
Assert.assertTrue("Item should be identical in DynamoDB and Phoenix",
ItemComparator.areItemsEqual(dynamoResult.item(),
phoenixResult.item()));
}
+
+ //
---------------------------------------------------------------------------------
+ // Grammar / structure validation: malformed UpdateExpression strings
should be
+ // rejected with HTTP 400 by both DynamoDB and the Phoenix adapter.
+ //
---------------------------------------------------------------------------------
+
+ @Test(timeout = 120000)
+ public void testEmptySetClauseBody() {
+ testUpdateExpressionFailure(tableName, "SET ", null, null, "empty SET
body");
+ }
+
+ @Test(timeout = 120000)
+ public void testEmptyRemoveClauseBody() {
+ testUpdateExpressionFailure(tableName, "REMOVE ", null, null, "empty
REMOVE body");
+ }
+
+ @Test(timeout = 120000)
+ public void testEmptyAddClauseBody() {
+ testUpdateExpressionFailure(tableName, "ADD ", null, null, "empty ADD
body");
+ }
+
+ @Test(timeout = 120000)
+ public void testEmptyDeleteClauseBody() {
+ testUpdateExpressionFailure(tableName, "DELETE ", null, null, "empty
DELETE body");
+ }
+
+ @Test(timeout = 120000)
+ public void testDuplicatePathInSet() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a = :v1, a = :v2", null, v,
+ "duplicate path in SET");
+ }
+
+ @Test(timeout = 120000)
+ public void testPathOverlapInSet() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a.b = :v1, a = :v2", null,
v,
+ "overlapping paths in SET");
+ }
+
+ @Test(timeout = 120000)
+ public void testPathOverlapInRemove() {
+ testUpdateExpressionFailure(tableName, "REMOVE a, a.b", null, null,
+ "overlapping paths in REMOVE");
+ }
+
+ @Test(timeout = 120000)
+ public void testDuplicateClauseKeyword() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a = :v1 SET b = :v2",
null, v,
+ "duplicate SET keyword");
+ }
+
+ /**
+ * SET keyword appears twice with another clause interleaved between the
two SETs.
+ * Different parse path than the back-to-back duplicate; both DDB and
adapter still 400.
+ */
+ @Test(timeout = 120000)
+ public void testDuplicateClauseKeywordInterleaved() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName,
+ "SET a = :v1 REMOVE c SET b = :v2", null, v,
+ "duplicate SET keyword interleaved with REMOVE");
+ }
+
+ @Test(timeout = 120000)
+ public void testIfNotExistsArityThree() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a = if_not_exists(a, :v,
:v2)", null, v,
+ "if_not_exists arity 3");
+ }
+
+ @Test(timeout = 120000)
+ public void testIfNotExistsArityOne() {
+ testUpdateExpressionFailure(tableName, "SET a = if_not_exists(a)",
null, null,
+ "if_not_exists arity 1");
+ }
+
+ @Test(timeout = 120000)
+ public void testIfNotExistsOutsideSet() {
+ Map<String, AttributeValue> v = Collections.singletonMap(":v",
+ AttributeValue.builder().n("1").build());
+ testUpdateExpressionFailure(tableName, "ADD a if_not_exists(a, :v)",
null, v,
+ "if_not_exists in ADD");
+ }
+
+ @Test(timeout = 120000)
+ public void testListAppendOutsideSet() {
+ Map<String, AttributeValue> v = Collections.singletonMap(":v",
+
AttributeValue.builder().l(AttributeValue.builder().s("x").build()).build());
+ testUpdateExpressionFailure(tableName, "ADD a list_append(a, :v)",
null, v,
+ "list_append in ADD");
+ }
+
+ @Test(timeout = 120000)
+ public void testUndeclaredExpressionAttributeName() {
+ Map<String, AttributeValue> v = Collections.singletonMap(":v",
+ AttributeValue.builder().s("x").build());
+ testUpdateExpressionFailure(tableName, "SET #unknown = :v", null, v,
+ "undeclared expression attribute name");
+ }
+
+ @Test(timeout = 120000)
+ public void testUndeclaredExpressionAttributeValueInSet() {
+ testUpdateExpressionFailure(tableName, "SET a = :missing", null, null,
+ "undeclared expression attribute value in SET");
+ }
+
+ @Test(timeout = 120000)
+ public void testUndeclaredExpressionAttributeValueInAdd() {
+ testUpdateExpressionFailure(tableName, "ADD a :missing", null, null,
+ "undeclared expression attribute value in ADD");
+ }
+
+ @Test(timeout = 120000)
+ public void testUndeclaredExpressionAttributeValueInDelete() {
+ testUpdateExpressionFailure(tableName, "DELETE a :missing", null, null,
+ "undeclared expression attribute value in DELETE");
+ }
+
+ @Test(timeout = 120000)
+ public void testRemoveAndSetOnSamePath() {
+ Map<String, AttributeValue> v = Collections.singletonMap(":v",
+ AttributeValue.builder().s("x").build());
+ testUpdateExpressionFailure(tableName, "SET a = :v REMOVE a", null, v,
+ "SET and REMOVE on same path");
+ }
+
+ /**
+ * {@code list_append(...) + list_append(...)} — list values cannot be
arithmetic operands.
+ * Both DDB and the adapter return 400.
+ */
+ @Test(timeout = 120000)
+ public void testListAppendAsArithmeticOperandRejected() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v", AttributeValue.builder().l(
+ AttributeValue.builder().s("x").build()).build());
+ v.put(":w", AttributeValue.builder().l(
+ AttributeValue.builder().s("y").build()).build());
+ testUpdateExpressionFailure(tableName,
+ "SET col = list_append(a, :v) + list_append(:w, b)", null, v,
+ "list_append cannot be an arithmetic operand");
+ }
+
+ /**
+ * Arithmetic where the {@code if_not_exists} fallback resolves to a
non-numeric placeholder.
+ * The visitor type-checks the placeholder at parse time so the 400 is
anchored at the
+ * adapter's parser layer; DDB rejects with the same status.
+ */
+ @Test(timeout = 120000)
+ public void testArithmeticIfNotExistsNonNumericFallbackRejected() {
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":s", AttributeValue.builder().s("notANumber").build());
+ v.put(":one", AttributeValue.builder().n("1").build());
+ testUpdateExpressionFailure(tableName,
+ "SET counter = if_not_exists(c, :s) + :one", null, v,
+ "arithmetic with non-numeric if_not_exists fallback");
+ }
+
+ /**
+ * Probe DDB and the adapter for case-sensitivity of clause keywords and
function names.
+ * Asserts parity (both backends return the same status) for each variant.
If DDB accepts
+ * a case-mixed form that the adapter rejects (or vice versa), this test
fails with a
+ * clear message naming the offending input — that's how we discover
whether our grammar
+ * is in fact stricter or looser than DDB.
+ */
+ @Test(timeout = 120000)
+ public void testCaseSensitivityParity() {
+ // Re-seed the item before each iteration since some cases may mutate
it.
+ Map<String, AttributeValue> seed = getKey();
+ seed.put("a", AttributeValue.builder().ss("x").build());
+ seed.put("evt", AttributeValue.builder().l(
+ AttributeValue.builder().s("e0").build()).build());
+
+ AttributeValue numV = AttributeValue.builder().n("1").build();
+ AttributeValue ssV = AttributeValue.builder().ss("y").build();
+ AttributeValue listV = AttributeValue.builder().l(
+ AttributeValue.builder().s("e1").build()).build();
+ AttributeValue emptyL =
AttributeValue.builder().l(java.util.Collections.emptyList()).build();
+
+ // Each case: input expression -> the values map sized to exactly what
it references.
+ Map<String, Map<String, AttributeValue>> cases = new
java.util.LinkedHashMap<>();
+ cases.put("SET a = :v", singleValue(":v", numV));
+ cases.put("set a = :v", singleValue(":v", numV));
+ cases.put("Set a = :v", singleValue(":v", numV));
+ cases.put("REMOVE a", null);
+ cases.put("remove a", null);
+ cases.put("Remove a", null);
+ cases.put("ADD a :v", singleValue(":v", ssV));
+ cases.put("add a :v", singleValue(":v", ssV));
+ cases.put("Add a :v", singleValue(":v", ssV));
+ cases.put("DELETE a :v", singleValue(":v", ssV));
+ cases.put("delete a :v", singleValue(":v", ssV));
+ cases.put("Delete a :v", singleValue(":v", ssV));
+ cases.put("SET myCounter = if_not_exists(myCounter, :one)",
+ singleValue(":one", numV));
+ cases.put("SET myCounter = If_Not_Exists(myCounter, :one)",
+ singleValue(":one", numV));
+ cases.put("SET myCounter = IF_NOT_EXISTS(myCounter, :one)",
+ singleValue(":one", numV));
+ cases.put("SET evt = list_append(evt, :more)",
+ singleValue(":more", listV));
+ cases.put("SET evt = List_Append(evt, :more)",
+ singleValue(":more", listV));
+ cases.put("SET evt = LIST_APPEND(evt, :more)",
+ singleValue(":more", listV));
+
+ StringBuilder mismatches = new StringBuilder();
+ for (Map.Entry<String, Map<String, AttributeValue>> e :
cases.entrySet()) {
+ String expr = e.getKey();
+ // Reset the seed before every probe so prior mutations don't
influence later cases.
+ putTestItem(tableName, seed);
+
+ UpdateItemRequest req = buildUpdateItemRequest(tableName, expr,
null, e.getValue());
+ int ddbStatus = invokeStatus(() -> dynamoDbClient.updateItem(req));
+ int phxStatus = invokeStatus(() ->
phoenixDBClientV2.updateItem(req));
+ if (ddbStatus != phxStatus) {
+ mismatches.append("\n expr=`").append(expr)
+ .append("` ddb=").append(ddbStatus).append("
phoenix=").append(phxStatus);
+ }
+ }
+ Assert.assertEquals("Case-sensitivity parity mismatches:" + mismatches,
+ 0, mismatches.length());
+ }
+
+ private static Map<String, AttributeValue> singleValue(String name,
AttributeValue v) {
+ return Collections.singletonMap(name, v);
+ }
+
+ /**
+ * One UpdateItem call combining all four clause keywords in mixed case.
Confirms the
+ * lexer handles every clause's case-insensitivity end-to-end and that DDB
and the adapter
+ * end up with byte-equal items.
+ */
+ @Test(timeout = 120000)
+ public void testAllClausesMixedCaseRoundTrip() {
+ Map<String, AttributeValue> seed = getKey();
+ seed.put("myCounter", AttributeValue.builder().n("10").build());
+ seed.put("rmField", AttributeValue.builder().s("removeMe").build());
+ seed.put("addCnt", AttributeValue.builder().n("5").build());
+ seed.put("delSet", AttributeValue.builder().ss("a", "b", "c").build());
+ putTestItem(tableName, seed);
+
+ Map<String, AttributeValue> values = new HashMap<>();
+ values.put(":inc", AttributeValue.builder().n("3").build());
+ values.put(":addV", AttributeValue.builder().n("7").build());
+ values.put(":delV", AttributeValue.builder().ss("a").build());
+ testUpdateExpressionSuccess(tableName,
+ "Set myCounter = myCounter + :inc remove rmField ADD addCnt
:addV dElEtE delSet :delV",
+ null, values, "all four clauses in mixed case");
+ }
+
+ /** Returns 200 if the action succeeded, otherwise the DynamoDbException
status code. */
+ private int invokeStatus(Runnable action) {
+ try {
+ action.run();
+ return 200;
+ } catch (DynamoDbException e) {
+ return e.statusCode();
+ }
+ }
+
+ /**
+ * Bare attribute name cannot start with a digit per DDB's expression
syntax. Aliased paths
+ * starting with a digit (e.g. {@code #5star}) are still accepted —
covered separately by
+ * the nested-alias-path tests.
+ */
+ @Test(timeout = 120000)
+ public void testBareAttributeNameStartingWithDigitRejected() {
+ Map<String, AttributeValue> v = Collections.singletonMap(":v",
+ AttributeValue.builder().s("x").build());
+ testUpdateExpressionFailure(tableName, "SET 5col = :v", null, v,
+ "bare attribute name starting with digit");
+ }
+
+ @Test(timeout = 120000)
+ public void testEmptyUpdateExpression() {
+ testUpdateExpressionFailure(tableName, "", null, null, "empty
UpdateExpression");
+ }
+
+ @Test(timeout = 120000)
+ public void testWhitespaceOnlyUpdateExpression() {
+ testUpdateExpressionFailure(tableName, " ", null, null,
+ "whitespace-only UpdateExpression");
+ }
+
+ /**
+ * Parent vs nested-field overlap with seed where {@code a} is a Map.
Without parser-level
+ * overlap detection this would have FAILED parity (DDB 400, Phoenix 200
with silent overwrite)
+ * because the BSON descent succeeds when the parent is actually a map.
+ */
+ @Test(timeout = 120000)
+ public void testPathOverlapInSetWhenParentIsMap() {
+ Map<String, AttributeValue> nested = new HashMap<>();
+ nested.put("b", AttributeValue.builder().s("inner").build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("a", AttributeValue.builder().m(nested).build());
+ putTestItem(tableName, item);
+
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a.b = :v1, a = :v2", null,
v,
+ "parent/nested-field overlap with map-typed parent");
+ }
+
+ /**
+ * Parent vs array-index overlap with seed where {@code a} is a List.
+ */
+ @Test(timeout = 120000)
+ public void testPathOverlapInSetWhenParentIsList() {
+ Map<String, AttributeValue> item = getKey();
+ item.put("a", AttributeValue.builder().l(
+ AttributeValue.builder().s("x").build()).build());
+ putTestItem(tableName, item);
+
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("y").build());
+ v.put(":v2", AttributeValue.builder().l(
+ AttributeValue.builder().s("z").build()).build());
+ testUpdateExpressionFailure(tableName, "SET a[0] = :v1, a = :v2",
null, v,
+ "parent/array-index overlap with list-typed parent");
+ }
+
+ /**
+ * Same-clause overlap between two nested paths where one is a strict
prefix of the other.
+ */
+ @Test(timeout = 120000)
+ public void testPathOverlapNestedPrefixInSet() {
+ Map<String, AttributeValue> inner = new HashMap<>();
+ inner.put("c", AttributeValue.builder().s("z").build());
+ Map<String, AttributeValue> outer = new HashMap<>();
+ outer.put("b", AttributeValue.builder().m(inner).build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("a", AttributeValue.builder().m(outer).build());
+ putTestItem(tableName, item);
+
+ Map<String, AttributeValue> v = new HashMap<>();
+ v.put(":v1", AttributeValue.builder().s("x").build());
+ v.put(":v2", AttributeValue.builder().s("y").build());
+ testUpdateExpressionFailure(tableName, "SET a.b = :v1, a.b.c = :v2",
null, v,
+ "two paths in same SET clause where one is a strict prefix of
the other");
+ }
+
+ /**
+ * Mixed alias + bare-name nested path on the SET LHS: {@code SET
#a.bareField = :v} where
+ * {@code #a} resolves to a top-level Map.
+ */
+ @Test(timeout = 120000)
+ public void testAliasThenBareNamePathRoundTrip() {
+ Map<String, AttributeValue> nested = new HashMap<>();
+ nested.put("bareField", AttributeValue.builder().s("orig").build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("topMap", AttributeValue.builder().m(nested).build());
+ putTestItem(tableName, item);
+
+ Map<String, String> aliases = Collections.singletonMap("#a", "topMap");
+ Map<String, AttributeValue> values = Collections.singletonMap(":v",
+ AttributeValue.builder().s("updated").build());
+ testUpdateExpressionSuccess(tableName, "SET #a.bareField = :v",
aliases, values,
+ "alias.bareName SET");
+ }
+
+ /**
+ * {@code SET bareName.#a = :v} — bare top-level name then alias step.
+ */
+ @Test(timeout = 120000)
+ public void testBareNameThenAliasPathRoundTrip() {
+ Map<String, AttributeValue> nested = new HashMap<>();
+ nested.put("deepField", AttributeValue.builder().s("orig").build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("topMap", AttributeValue.builder().m(nested).build());
+ putTestItem(tableName, item);
+
+ Map<String, String> aliases = Collections.singletonMap("#a",
"deepField");
+ Map<String, AttributeValue> values = Collections.singletonMap(":v",
+ AttributeValue.builder().s("updated").build());
+ testUpdateExpressionSuccess(tableName, "SET topMap.#a = :v", aliases,
values,
+ "bareName.alias SET");
+ }
+
+ /**
+ * {@code SET #l[0].#nested = :v} — alias with index then alias.
+ */
+ @Test(timeout = 120000)
+ public void testAliasArrayIndexThenAliasPathRoundTrip() {
+ Map<String, AttributeValue> inner = new HashMap<>();
+ inner.put("deepField", AttributeValue.builder().s("orig").build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("myList", AttributeValue.builder().l(
+ AttributeValue.builder().m(inner).build()).build());
+ putTestItem(tableName, item);
+
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#l", "myList");
+ aliases.put("#nested", "deepField");
+ Map<String, AttributeValue> values = Collections.singletonMap(":v",
+ AttributeValue.builder().s("updated").build());
+ testUpdateExpressionSuccess(tableName, "SET #l[0].#nested = :v",
aliases, values,
+ "alias[idx].alias SET");
+ }
+
+ /**
+ * {@code SET col = if_not_exists(#a.b, :v)} where {@code #a.b} does NOT
exist; fallback wins.
+ */
+ @Test(timeout = 120000)
+ public void testAliasPathInsideIfNotExistsRoundTrip() {
+ putTestItem(tableName, getKey());
+
+ Map<String, String> aliases = Collections.singletonMap("#a", "topMap");
+ Map<String, AttributeValue> values = Collections.singletonMap(":v",
+ AttributeValue.builder().s("fallback").build());
+ testUpdateExpressionSuccess(tableName,
+ "SET col = if_not_exists(#a.b, :v)", aliases, values,
+ "alias path inside if_not_exists");
+ }
+
+ /**
+ * {@code SET col = list_append(if_not_exists(#l, :empty), :items)}.
+ */
+ @Test(timeout = 120000)
+ public void testAliasInsideIfNotExistsInsideListAppendRoundTrip() {
+ putTestItem(tableName, getKey());
+
+ Map<String, String> aliases = Collections.singletonMap("#l", "events");
+ Map<String, AttributeValue> values = new HashMap<>();
+ values.put(":empty",
AttributeValue.builder().l(Collections.emptyList()).build());
+ values.put(":items", AttributeValue.builder().l(
+ AttributeValue.builder().s("a").build(),
+ AttributeValue.builder().s("b").build()).build());
+ testUpdateExpressionSuccess(tableName,
+ "SET col = list_append(if_not_exists(#l, :empty), :items)",
aliases, values,
+ "alias inside if_not_exists inside list_append");
+ }
+
+ /**
+ * Multi-step alias-heavy nested path on LHS, mirroring the AWS docs
example shape.
+ */
+ @Test(timeout = 120000)
+ public void testNestedAliasPathRoundTrip() {
+ Map<String, AttributeValue> reviewerEntry = new HashMap<>();
+ reviewerEntry.put("reviewer",
AttributeValue.builder().s("alice").build());
+ Map<String, AttributeValue> reviews = new HashMap<>();
+ reviews.put("FiveStar", AttributeValue.builder().l(
+ AttributeValue.builder().m(reviewerEntry).build(),
+ AttributeValue.builder().m(reviewerEntry).build()).build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("ProductReviews",
AttributeValue.builder().m(reviews).build());
+ putTestItem(tableName, item);
+
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#pr", "ProductReviews");
+ aliases.put("#5star", "FiveStar");
+ Map<String, AttributeValue> values = Collections.singletonMap(":v",
+ AttributeValue.builder().s("bob").build());
+ testUpdateExpressionSuccess(tableName,
+ "SET #pr.#5star[1].reviewer = :v", aliases, values,
+ "alias.alias[idx].bareName SET");
+ }
+
+ /**
+ * Alias paths used in REMOVE / ADD / DELETE in a single expression. Pins
parity for the
+ * non-SET clauses since the rest of the alias-path tests target SET LHS
only.
+ */
+ @Test(timeout = 120000)
+ public void testAliasPathsInRemoveAddDeleteRoundTrip() {
+ Map<String, AttributeValue> nested = new HashMap<>();
+ nested.put("leafField", AttributeValue.builder().s("orig").build());
+ Map<String, AttributeValue> item = getKey();
+ item.put("topMap", AttributeValue.builder().m(nested).build());
+ item.put("counter", AttributeValue.builder().n("5").build());
+ item.put("myStringSet", AttributeValue.builder().ss("a", "b").build());
+ putTestItem(tableName, item);
+
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#a", "topMap");
+ aliases.put("#leaf", "leafField");
+ aliases.put("#c", "counter");
+ aliases.put("#s", "myStringSet");
+ Map<String, AttributeValue> values = new HashMap<>();
+ values.put(":n", AttributeValue.builder().n("3").build());
+ values.put(":subset", AttributeValue.builder().ss("a").build());
+ testUpdateExpressionSuccess(tableName,
+ "REMOVE #a.#leaf ADD #c :n DELETE #s :subset", aliases, values,
+ "alias paths in REMOVE/ADD/DELETE");
+ }
+
+ /**
+ * Alias-as-keyword parity: an ExpressionAttributeName whose value is the
literal string
+ * {@code "ADD"} (a clause keyword) must produce byte-equal items between
DDB and the
+ * adapter. Pre-ANTLR the regex parser misclassified the substituted token
and corrupted
+ * the resulting BSON.
+ */
+ @Test(timeout = 120000)
+ public void testAliasResolvingToClauseKeywordRoundTrip() {
+ Map<String, String> aliases = new HashMap<>();
+ aliases.put("#attr", "ADD");
+
+ Map<String, AttributeValue> values = new HashMap<>();
+ values.put(":v", AttributeValue.builder().s("hello").build());
+
+ UpdateItemRequest req =
UpdateItemRequest.builder().tableName(tableName).key(getKey())
+ .updateExpression("SET #attr = :v REMOVE oldField")
+ .expressionAttributeNames(aliases)
+ .expressionAttributeValues(values).build();
+ dynamoDbClient.updateItem(req);
+ phoenixDBClientV2.updateItem(req);
+ validateItem(tableName, getKey());
+ }
+
+ //
---------------------------------------------------------------------------------
+ // Clause ordering: every non-empty subset of {SET,REMOVE,ADD,DELETE} in
every
+ // ordering, asserting DDB and Phoenix end up with byte-equal items.
Looped in a
+ // single test method to avoid the per-test table create/drop and another
mini-cluster.
+ //
---------------------------------------------------------------------------------
+
+ @Test(timeout = 600000)
+ public void testAllClauseOrderingsProduceIdenticalItem() {
+ String[] all = {"SET", "REMOVE", "ADD", "DELETE"};
+ for (int mask = 1; mask < (1 << all.length); mask++) {
+ List<String> chosen = new ArrayList<>();
+ for (int i = 0; i < all.length; i++) {
+ if ((mask & (1 << i)) != 0) {
+ chosen.add(all[i]);
+ }
+ }
+ for (List<String> ordering : permutations(chosen)) {
+ runClauseOrdering(ordering);
+ }
+ }
+ }
+
+ private void runClauseOrdering(List<String> clauseOrder) {
+ // PutItem fully replaces, so this resets any attributes a prior
iteration mutated.
+ Map<String, AttributeValue> seed = getKey();
+ seed.put("cnt", AttributeValue.builder().n("10").build());
+ seed.put("rmField", AttributeValue.builder().s("removeMe").build());
+ seed.put("addCnt", AttributeValue.builder().n("5").build());
+ seed.put("delSet", AttributeValue.builder().ss("a", "b", "c").build());
+ putTestItem(tableName, seed);
+
+ StringBuilder expr = new StringBuilder();
+ for (String clause : clauseOrder) {
+ if (expr.length() > 0) {
+ expr.append(' ');
+ }
+ expr.append(clause).append(' ').append(clauseBody(clause));
+ }
+
+ Map<String, AttributeValue> values = new HashMap<>();
+ if (clauseOrder.contains("SET")) {
+ values.put(":inc", AttributeValue.builder().n("3").build());
+ }
+ if (clauseOrder.contains("ADD")) {
+ values.put(":addV", AttributeValue.builder().n("7").build());
+ }
+ if (clauseOrder.contains("DELETE")) {
+ values.put(":delV", AttributeValue.builder().ss("a").build());
+ }
+
+ UpdateItemRequest.Builder rb =
UpdateItemRequest.builder().tableName(tableName).key(getKey())
+ .updateExpression(expr.toString());
+ if (!values.isEmpty()) {
+ rb.expressionAttributeValues(values);
+ }
+ UpdateItemRequest req = rb.build();
+ dynamoDbClient.updateItem(req);
+ phoenixDBClientV2.updateItem(req);
+
+ GetItemRequest gir =
GetItemRequest.builder().tableName(tableName).key(getKey()).build();
+ GetItemResponse ddb = dynamoDbClient.getItem(gir);
+ GetItemResponse phx = phoenixDBClientV2.getItem(gir);
+ Assert.assertTrue("Items should match for ordering " + clauseOrder + "
expr=" + expr,
+ ItemComparator.areItemsEqual(ddb.item(), phx.item()));
+ }
+
+ private static String clauseBody(String clause) {
+ switch (clause) {
+ case "SET": return "cnt = cnt + :inc";
+ case "REMOVE": return "rmField";
+ case "ADD": return "addCnt :addV";
+ case "DELETE": return "delSet :delV";
+ default: throw new IllegalArgumentException(clause);
+ }
+ }
+
+ private static List<List<String>> permutations(List<String> in) {
+ List<List<String>> out = new ArrayList<>();
+ if (in.size() == 1) {
+ out.add(new ArrayList<>(in));
+ return out;
+ }
+ for (int i = 0; i < in.size(); i++) {
+ List<String> rest = new ArrayList<>(in);
+ String head = rest.remove(i);
+ for (List<String> tail : permutations(rest)) {
+ List<String> p = new ArrayList<>();
+ p.add(head);
+ p.addAll(tail);
+ out.add(p);
+ }
+ }
+ return out;
+ }
}
diff --git
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemIT.java
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemIT.java
index 344fa0d..9e1d71b 100644
--- a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemIT.java
+++ b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemIT.java
@@ -33,6 +33,7 @@ import
software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedExce
import software.amazon.awssdk.services.dynamodb.model.DynamoDbException;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
+import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.ReturnValue;
import
software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;
import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;
@@ -673,4 +674,252 @@ public class UpdateItemIT extends UpdateItemBaseTests {
// Verify final state by querying both Phoenix and DDB
validateItem(tableName, key);
}
+
+ //
---------------------------------------------------------------------------------
+ // Canonical DynamoDB UpdateExpression docs examples. Each test seeds a
custom item
+ // and asserts the adapter and real DDB end up with byte-equal items.
+ //
---------------------------------------------------------------------------------
+
+ /**
+ * Docs example: SET that simultaneously updates a list element and a
scalar.
+ * "SET RelatedItems[1] = :newValue, Price = :newPrice"
+ */
+ @Test(timeout = 120000)
+ public void testDocsSetListElementAndScalarTogether() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("Price", AttributeValue.builder().n("52").build());
+ item.put("RelatedItems", AttributeValue.builder().l(
+ AttributeValue.builder().s("Hammer").build(),
+ AttributeValue.builder().s("Nails").build()).build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = new HashMap<>();
+ values.put(":newValue",
AttributeValue.builder().s("Screwdriver").build());
+ values.put(":newPrice", AttributeValue.builder().n("60").build());
+ runUpdateAndCompare(tableName, key,
+ "SET RelatedItems[1] = :newValue, Price = :newPrice", values);
+ }
+
+ /**
+ * Docs example: SET arithmetic combined with REMOVE in the same
expression.
+ * "SET Price = Price - :p REMOVE InStock"
+ */
+ @Test(timeout = 120000)
+ public void testDocsSetArithmeticAndRemoveTogether() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("Price", AttributeValue.builder().n("60").build());
+ item.put("InStock", AttributeValue.builder().bool(true).build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":p",
+ AttributeValue.builder().n("15").build());
+ runUpdateAndCompare(tableName, key,
+ "SET Price = Price - :p REMOVE InStock", values);
+ }
+
+ /**
+ * Docs example: REMOVE multiple list elements in one expression. The
remaining
+ * elements compact (shift), matching the documented behaviour.
+ */
+ @Test(timeout = 120000)
+ public void testDocsRemoveMultipleListElements() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("RelatedItems", AttributeValue.builder().l(
+ AttributeValue.builder().s("Chisel").build(),
+ AttributeValue.builder().s("Hammer").build(),
+ AttributeValue.builder().s("Nails").build(),
+ AttributeValue.builder().s("Screwdriver").build(),
+ AttributeValue.builder().s("Hacksaw").build()).build());
+ seedItem(tableName, item);
+ runUpdateAndCompare(tableName, key,
+ "REMOVE RelatedItems[1], RelatedItems[2]", null);
+ }
+
+ /**
+ * Remove first (head) element of a list.
+ */
+ @Test(timeout = 120000)
+ public void testDocsRemoveListHead() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("L", AttributeValue.builder().l(
+ AttributeValue.builder().s("a").build(),
+ AttributeValue.builder().s("b").build(),
+ AttributeValue.builder().s("c").build()).build());
+ seedItem(tableName, item);
+ runUpdateAndCompare(tableName, key, "REMOVE L[0]", null);
+ }
+
+ /**
+ * Remove last (tail) element of a list.
+ */
+ @Test(timeout = 120000)
+ public void testDocsRemoveListTail() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("L", AttributeValue.builder().l(
+ AttributeValue.builder().s("a").build(),
+ AttributeValue.builder().s("b").build(),
+ AttributeValue.builder().s("c").build()).build());
+ seedItem(tableName, item);
+ runUpdateAndCompare(tableName, key, "REMOVE L[2]", null);
+ }
+
+ /**
+ * if_not_exists where the path DOES exist returns the existing value, not
the fallback.
+ */
+ @Test(timeout = 120000)
+ public void testDocsIfNotExistsBranchExisting() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("Title", AttributeValue.builder().s("OriginalTitle").build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":t",
+ AttributeValue.builder().s("FallbackTitle").build());
+ runUpdateAndCompare(tableName, key,
+ "SET Title = if_not_exists(Title, :t)", values);
+ }
+
+ /**
+ * if_not_exists where the path is missing returns the fallback value.
+ */
+ @Test(timeout = 120000)
+ public void testDocsIfNotExistsBranchMissing() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ seedItem(tableName, new HashMap<>(key));
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":t",
+ AttributeValue.builder().s("FallbackTitle").build());
+ runUpdateAndCompare(tableName, key,
+ "SET Title = if_not_exists(Title, :t)", values);
+ }
+
+ /**
+ * DELETE elements from a Number Set (NS).
+ */
+ @Test(timeout = 120000)
+ public void testDocsDeleteFromNumberSet() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("Scores", AttributeValue.builder().ns("1", "2", "3",
"4").build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":r",
+ AttributeValue.builder().ns("2", "4").build());
+ runUpdateAndCompare(tableName, key, "DELETE Scores :r", values);
+ }
+
+ /**
+ * DELETE elements from a Binary Set (BS).
+ */
+ @Test(timeout = 120000)
+ public void testDocsDeleteFromBinarySet() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("BinSet", AttributeValue.builder().bs(
+ SdkBytes.fromByteArray(new byte[] {1}),
+ SdkBytes.fromByteArray(new byte[] {2}),
+ SdkBytes.fromByteArray(new byte[] {3})).build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":r",
+ AttributeValue.builder().bs(SdkBytes.fromByteArray(new byte[]
{2})).build());
+ runUpdateAndCompare(tableName, key, "DELETE BinSet :r", values);
+ }
+
+ /**
+ * ADD into a Binary Set (BS) on an existing set.
+ */
+ @Test(timeout = 120000)
+ public void testDocsAddBinarySet() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("BinSet", AttributeValue.builder().bs(
+ SdkBytes.fromByteArray(new byte[] {1})).build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> values = Collections.singletonMap(":a",
+ AttributeValue.builder().bs(
+ SdkBytes.fromByteArray(new byte[] {2}),
+ SdkBytes.fromByteArray(new byte[] {3})).build());
+ runUpdateAndCompare(tableName, key, "ADD BinSet :a", values);
+ }
+
+ /**
+ * list_append on a list whose elements are themselves Maps.
+ */
+ @Test(timeout = 120000)
+ public void testDocsListAppendOnListOfMaps() {
+ final String tableName =
testName.getMethodName().replaceAll("[\\[\\]]", "");
+ createTableAndPutItem(tableName, false);
+ Map<String, AttributeValue> key = getKey();
+
+ Map<String, AttributeValue> existing = new HashMap<>();
+ existing.put("name", AttributeValue.builder().s("alice").build());
+ Map<String, AttributeValue> item = new HashMap<>(key);
+ item.put("Audit", AttributeValue.builder().l(
+ AttributeValue.builder().m(existing).build()).build());
+ seedItem(tableName, item);
+
+ Map<String, AttributeValue> appended = new HashMap<>();
+ appended.put("name", AttributeValue.builder().s("bob").build());
+ Map<String, AttributeValue> values = Collections.singletonMap(":new",
+ AttributeValue.builder().l(
+ AttributeValue.builder().m(appended).build()).build());
+ runUpdateAndCompare(tableName, key,
+ "SET Audit = list_append(Audit, :new)", values);
+ }
+
+ private void seedItem(String tableName, Map<String, AttributeValue> item) {
+ PutItemRequest put =
PutItemRequest.builder().tableName(tableName).item(item).build();
+ dynamoDbClient.putItem(put);
+ phoenixDBClientV2.putItem(put);
+ }
+
+ private void runUpdateAndCompare(String tableName, Map<String,
AttributeValue> key,
+ String updateExpression, Map<String, AttributeValue> values) {
+ UpdateItemRequest.Builder b =
UpdateItemRequest.builder().tableName(tableName).key(key)
+ .updateExpression(updateExpression);
+ if (values != null) {
+ b.expressionAttributeValues(values);
+ }
+ UpdateItemRequest req = b.build();
+ dynamoDbClient.updateItem(req);
+ phoenixDBClientV2.updateItem(req);
+ validateItem(tableName, key);
+ }
}
diff --git a/phoenix-ddb-utils/pom.xml b/phoenix-ddb-utils/pom.xml
index 178e329..b4bb526 100644
--- a/phoenix-ddb-utils/pom.xml
+++ b/phoenix-ddb-utils/pom.xml
@@ -51,6 +51,32 @@
<artifactId>phoenix-core-server</artifactId>
<version>${phoenix.version}</version>
</dependency>
+
+ <dependency>
+ <groupId>org.antlr</groupId>
+ <artifactId>antlr4-runtime</artifactId>
+ <version>${antlr4.version}</version>
+ </dependency>
</dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.antlr</groupId>
+ <artifactId>antlr4-maven-plugin</artifactId>
+ <version>${antlr4.version}</version>
+ <executions>
+ <execution>
+ <id>antlr</id>
+ <goals><goal>antlr4</goal></goals>
+ </execution>
+ </executions>
+ <configuration>
+ <visitor>true</visitor>
+ <listener>false</listener>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
</project>
\ No newline at end of file
diff --git
a/phoenix-ddb-utils/src/main/antlr4/org/apache/phoenix/ddb/update/UpdateExpression.g4
b/phoenix-ddb-utils/src/main/antlr4/org/apache/phoenix/ddb/update/UpdateExpression.g4
new file mode 100644
index 0000000..85b315a
--- /dev/null
+++
b/phoenix-ddb-utils/src/main/antlr4/org/apache/phoenix/ddb/update/UpdateExpression.g4
@@ -0,0 +1,106 @@
+grammar UpdateExpression;
+
+updateExpression
+ : clause+ EOF
+ ;
+
+clause
+ : SET setAction (',' setAction)* # setClause
+ | REMOVE removeAction (',' removeAction)* # removeClause
+ | ADD addAction (',' addAction)* # addClause
+ | DELETE deleteAction (',' deleteAction)* # deleteClause
+ ;
+
+setAction
+ : path '=' setValue
+ ;
+
+setValue
+ : operand # setValueOperand
+ | operand '+' operand # setValueAdd
+ | operand '-' operand # setValueSubtract
+ ;
+
+operand
+ : path # operandPath
+ | placeholder # operandPlaceholder
+ | ifNotExists # operandIfNotExists
+ | listAppend # operandListAppend
+ ;
+
+ifNotExists
+ : IF_NOT_EXISTS '(' path ',' ifNotExistsValue ')'
+ ;
+
+ifNotExistsValue
+ : path # ifNotExistsValuePath
+ | placeholder # ifNotExistsValuePlaceholder
+ ;
+
+listAppend
+ : LIST_APPEND '(' listAppendOperand ',' listAppendOperand ')'
+ ;
+
+listAppendOperand
+ : path # listAppendOperandPath
+ | placeholder # listAppendOperandPlaceholder
+ | ifNotExists # listAppendOperandIfNotExists
+ ;
+
+removeAction
+ : path
+ ;
+
+addAction
+ : path placeholder
+ ;
+
+deleteAction
+ : path placeholder
+ ;
+
+path
+ : pathStep pathSuffix*
+ ;
+
+pathSuffix
+ : '.' pathStep
+ | '[' INT ']'
+ ;
+
+pathStep
+ : NAME
+ | ALIAS
+ ;
+
+placeholder
+ : VALUE_PLACEHOLDER
+ ;
+
+// Lexer rules. Order matters: keywords before NAME so they win over the
generic NAME rule.
+// Clause keywords are case-insensitive to match DDB (e.g. `set a = :v` is
valid).
+// Function names are case-sensitive per AWS docs ("The function name is case
sensitive").
+SET : S E T ;
+REMOVE : R E M O V E ;
+ADD : A D D ;
+DELETE : D E L E T E ;
+IF_NOT_EXISTS : 'if_not_exists' ;
+LIST_APPEND : 'list_append' ;
+
+ALIAS : '#' [a-zA-Z0-9_]+ ;
+VALUE_PLACEHOLDER : ':' [a-zA-Z0-9_]+ ;
+NAME : [a-zA-Z] [a-zA-Z0-9_]* ;
+INT : [0-9]+ ;
+
+WS : [ \t\r\n]+ -> skip ;
+
+fragment A : [aA] ;
+fragment D : [dD] ;
+fragment E : [eE] ;
+fragment L : [lL] ;
+fragment M : [mM] ;
+fragment O : [oO] ;
+fragment R : [rR] ;
+fragment S : [sS] ;
+fragment T : [tT] ;
+fragment V : [vV] ;
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/bson/UpdateExpressionDdbToBson.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/bson/UpdateExpressionDdbToBson.java
deleted file mode 100644
index aa404b4..0000000
---
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/bson/UpdateExpressionDdbToBson.java
+++ /dev/null
@@ -1,282 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you 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 org.apache.phoenix.ddb.bson;
-
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.bson.BsonArray;
-import org.bson.BsonDocument;
-import org.bson.BsonNull;
-import org.bson.BsonString;
-import org.bson.BsonValue;
-
-/**
- * Utility to convert DynamoDB UpdateExpression into BSON Update Expression.
- */
-public class UpdateExpressionDdbToBson {
-
- private static final String setRegExPattern =
"SET\\s+(.+?)(?=\\s+(REMOVE|ADD|DELETE)\\b|$)";
- private static final String removeRegExPattern =
"REMOVE\\s+(.+?)(?=\\s+(SET|ADD|DELETE)\\b|$)";
- private static final String addRegExPattern =
"ADD\\s+(.+?)(?=\\s+(SET|REMOVE|DELETE)\\b|$)";
- private static final String deleteRegExPattern =
"DELETE\\s+(.+?)(?=\\s+(SET|REMOVE|ADD)\\b|$)";
- private static final String ifNotExistsPattern =
"if_not_exists\\s*\\(\\s*([^,]+)\\s*,\\s*([^)]+)\\s*\\)";
- private static final String listAppendPattern =
"^list_append\\s*\\(\\s*(.+?)\\s*\\)$";
- private static final Pattern SET_PATTERN = Pattern.compile(setRegExPattern);
- private static final Pattern REMOVE_PATTERN =
Pattern.compile(removeRegExPattern);
- private static final Pattern ADD_PATTERN = Pattern.compile(addRegExPattern);
- private static final Pattern DELETE_PATTERN =
Pattern.compile(deleteRegExPattern);
- private static final Pattern IF_NOT_EXISTS_PATTERN =
Pattern.compile(ifNotExistsPattern);
- private static final Pattern LIST_APPEND_PATTERN =
Pattern.compile(listAppendPattern);
-
- public static BsonDocument getBsonDocumentForUpdateExpression(
- final String updateExpression,
- final BsonDocument comparisonValue) {
-
- String setString = "";
- String removeString = "";
- String addString = "";
- String deleteString = "";
-
- Matcher matcher = SET_PATTERN.matcher(updateExpression);
- if (matcher.find()) {
- setString = matcher.group(1).trim();
- }
-
- matcher = REMOVE_PATTERN.matcher(updateExpression);
- if (matcher.find()) {
- removeString = matcher.group(1).trim();
- }
-
- matcher = ADD_PATTERN.matcher(updateExpression);
- if (matcher.find()) {
- addString = matcher.group(1).trim();
- }
-
- matcher = DELETE_PATTERN.matcher(updateExpression);
- if (matcher.find()) {
- deleteString = matcher.group(1).trim();
- }
-
- BsonDocument bsonDocument = new BsonDocument();
- if (!setString.isEmpty()) {
- BsonDocument setBsonDoc = new BsonDocument();
- // Split on top-level commas only (commas inside any depth of parens are
skipped).
- String[] setExpressions = splitTopLevelCommas(setString);
- for (int i = 0; i < setExpressions.length; i++) {
- String setExpression = setExpressions[i].trim();
- String[] keyVal = setExpression.split("\\s*=\\s*");
- if (keyVal.length == 2) {
- String attributeKey = keyVal[0].trim();
- String attributeVal = keyVal[1].trim();
- if (attributeVal.startsWith("list_append")) {
- setBsonDoc.put(attributeKey, getListAppendDoc(attributeVal,
comparisonValue));
- } else if (attributeVal.contains("+") || attributeVal.contains("-"))
{
- setBsonDoc.put(attributeKey, getArithmeticDoc(attributeVal,
comparisonValue));
- } else if (attributeVal.startsWith("if_not_exists")) {
- setBsonDoc.put(attributeKey, getIfNotExistsDoc(attributeVal,
comparisonValue));
- } else {
- setBsonDoc.put(attributeKey, comparisonValue.get(attributeVal));
- }
- }
- else {
- throw new RuntimeException(
- "SET Expression " + setString + " does not include key value
pairs separated by =");
- }
- }
- bsonDocument.put("$SET", setBsonDoc);
- }
- if (!removeString.isEmpty()) {
- String[] removeExpressions = removeString.split(",");
- BsonDocument unsetBsonDoc = new BsonDocument();
- for (String removeAttribute : removeExpressions) {
- String attributeKey = removeAttribute.trim();
- unsetBsonDoc.put(attributeKey, new BsonNull());
- }
- bsonDocument.put("$UNSET", unsetBsonDoc);
- }
- if (!addString.isEmpty()) {
- String[] addExpressions = addString.split(",");
- BsonDocument addBsonDoc = new BsonDocument();
- for (String addExpression : addExpressions) {
- addExpression = addExpression.trim();
- String[] keyVal = addExpression.split("\\s+");
- if (keyVal.length == 2) {
- String attributeKey = keyVal[0].trim();
- String attributeVal = keyVal[1].trim();
- addBsonDoc.put(attributeKey, comparisonValue.get(attributeVal));
- } else {
- throw new RuntimeException("ADD Expression " + addString
- + " does not include key value pairs separated by space");
- }
- }
- bsonDocument.put("$ADD", addBsonDoc);
- }
- if (!deleteString.isEmpty()) {
- BsonDocument delBsonDoc = new BsonDocument();
- String[] deleteExpressions = deleteString.split(",");
- for (String deleteExpression : deleteExpressions) {
- deleteExpression = deleteExpression.trim();
- String[] keyVal = deleteExpression.split("\\s+");
- if (keyVal.length == 2) {
- String attributeKey = keyVal[0].trim();
- String attributeVal = keyVal[1].trim();
- delBsonDoc.put(attributeKey, comparisonValue.get(attributeVal));
- } else {
- throw new RuntimeException("DELETE Expression " + deleteString
- + " does not include key value pairs separated by space");
- }
- }
- bsonDocument.put("$DELETE_FROM_SET", delBsonDoc);
- }
- return bsonDocument;
- }
-
- private static BsonDocument getArithmeticDoc(String expr, BsonDocument
comparisonValue) {
- BsonDocument arithmeticDoc = new BsonDocument();
- String op;
- String[] operands;
- if (expr.contains("+")) {
- op = "$ADD";
- operands = expr.split("\\+");
- } else if (expr.contains("-")) {
- op = "$SUBTRACT";
- operands = expr.split("-");
- } else {
- throw new IllegalArgumentException("Unsupported arithmetic operator
for SET");
- }
- BsonArray bsonOperands = new BsonArray();
- for (String operand : operands) {
- operand = operand.trim();
- if (operand.startsWith("if_not_exists")) {
- bsonOperands.add(getIfNotExistsDoc(operand, comparisonValue));
- } else if (operand.startsWith(":")) {
- BsonValue bsonValue = comparisonValue.get(operand);
- if (!bsonValue.isNumber() && !bsonValue.isDecimal128()) {
- throw new IllegalArgumentException(
- "Operand " + operand + " is not provided as number
type");
- }
- bsonOperands.add(bsonValue);
- } else {
- bsonOperands.add(new BsonString(operand));
- }
- }
- arithmeticDoc.put(op, bsonOperands);
- return arithmeticDoc;
- }
-
- private static BsonDocument getIfNotExistsDoc(String expr, BsonDocument
comparisonValue) {
- Matcher m = IF_NOT_EXISTS_PATTERN.matcher(expr);
- if (m.find()) {
- String ifNotExistsPath = m.group(1).trim();
- String fallBackValue = m.group(2).trim();
- BsonValue fallBackValueBson = comparisonValue.get(fallBackValue);
- BsonDocument fallBackDoc = new BsonDocument();
- fallBackDoc.put(ifNotExistsPath, fallBackValueBson);
- BsonDocument ifNotExistsDoc = new BsonDocument();
- ifNotExistsDoc.put("$IF_NOT_EXISTS", fallBackDoc);
- return ifNotExistsDoc;
- } else {
- throw new RuntimeException("Invalid format for if_not_exists(path,
value)");
- }
- }
-
- private static BsonDocument getListAppendDoc(String expr, BsonDocument
comparisonValue) {
- Matcher m = LIST_APPEND_PATTERN.matcher(expr);
- if (!m.find()) {
- throw new IllegalArgumentException(
- "Invalid format for list_append(operand1, operand2): " +
expr);
- }
- String inner = m.group(1).trim();
- // Split on top-level commas only (commas inside any depth of parens are
skipped).
- String[] operands = splitTopLevelCommas(inner);
- if (operands.length != 2) {
- throw new IllegalArgumentException(
- "list_append requires exactly 2 operands, got " +
operands.length
- + " in: " + expr);
- }
- BsonArray bsonOperands = new BsonArray();
- for (String operand : operands) {
- bsonOperands.add(resolveListAppendOperand(operand.trim(),
comparisonValue));
- }
- BsonDocument listAppendDoc = new BsonDocument();
- listAppendDoc.put("$LIST_APPEND", bsonOperands);
- return listAppendDoc;
- }
-
- private static BsonValue resolveListAppendOperand(String operand,
- BsonDocument
comparisonValue) {
- if (operand.startsWith("if_not_exists")) {
- BsonDocument ifNotExistsDoc = getIfNotExistsDoc(operand,
comparisonValue);
- // Validate that the fallback value inside if_not_exists is a list,
matching
- // the client-side validation already done for literal placeholders.
- BsonDocument inner = ifNotExistsDoc.getDocument("$IF_NOT_EXISTS");
- BsonValue fallback = inner.values().iterator().next();
- if (fallback != null && !fallback.isNull() && !fallback.isArray()) {
- throw new IllegalArgumentException(
- "if_not_exists fallback inside list_append must resolve
to a List type"
- + " but got: " + operand);
- }
- return ifNotExistsDoc;
- } else if (operand.matches("^list_append\\s*\\(.*")) {
- throw new IllegalArgumentException(
- "Nested list_append is not supported as an operand: " +
operand);
- } else if (operand.startsWith(":")) {
- BsonValue bsonValue = comparisonValue.get(operand);
- if (bsonValue == null) {
- throw new IllegalArgumentException(
- "Operand " + operand
- + " not found in expression attribute values for
list_append");
- }
- if (!bsonValue.isArray()) {
- throw new IllegalArgumentException(
- "Operand " + operand + " for list_append must resolve to
a List type");
- }
- return bsonValue;
- } else {
- // bare attribute path (e.g. myList, nested.queue, matrix[0])
- return new BsonString(operand);
- }
- }
-
- /**
- * Split the given string on commas that are at paren-depth 0. Commas inside
any
- * level of {@code (...)} are preserved. This correctly handles arbitrarily
nested
- * function calls such as {@code list_append(:v, if_not_exists(a, :empty))}
and
- * {@code if_not_exists(a, :v), b = b + :n} at the SET-clause level.
- */
- private static String[] splitTopLevelCommas(String s) {
- java.util.List<String> parts = new java.util.ArrayList<>();
- int depth = 0;
- int last = 0;
- for (int i = 0; i < s.length(); i++) {
- char c = s.charAt(i);
- if (c == '(') {
- depth++;
- } else if (c == ')') {
- depth--;
- } else if (c == ',' && depth == 0) {
- parts.add(s.substring(last, i));
- last = i + 1;
- }
- }
- parts.add(s.substring(last));
- return parts.toArray(new String[0]);
- }
-}
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/BsonEmittingVisitor.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/BsonEmittingVisitor.java
new file mode 100644
index 0000000..3497c00
--- /dev/null
+++
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/BsonEmittingVisitor.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.phoenix.ddb.update;
+
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.bson.BsonArray;
+import org.bson.BsonDocument;
+import org.bson.BsonNull;
+import org.bson.BsonString;
+import org.bson.BsonValue;
+
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.AddActionContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.AddClauseContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.DeleteActionContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.DeleteClauseContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.IfNotExistsContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.IfNotExistsValuePathContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.IfNotExistsValuePlaceholderContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.ListAppendContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.ListAppendOperandContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.ListAppendOperandIfNotExistsContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.ListAppendOperandPathContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.ListAppendOperandPlaceholderContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.OperandContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.OperandIfNotExistsContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.OperandListAppendContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.OperandPathContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.OperandPlaceholderContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.RemoveActionContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.RemoveClauseContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.SetActionContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.SetClauseContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.SetValueAddContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.SetValueOperandContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.SetValueSubtractContext;
+import
org.apache.phoenix.ddb.update.UpdateExpressionParser.UpdateExpressionContext;
+
+/**
+ * Walks the parsed UpdateExpression and emits the BSON document consumed by
phoenix-1's
+ * {@code UpdateExpressionUtils}: {@code {"$SET": ..., "$UNSET": ..., "$ADD":
..., "$DELETE_FROM_SET": ...}}.
+ * Enforces the semantic rules the grammar can't (each clause keyword once,
path overlap,
+ * undeclared {@code :placeholder}, list_append operand types, arithmetic
operand numeric).
+ */
+final class BsonEmittingVisitor {
+
+ private enum Clause { SET, REMOVE, ADD, DELETE }
+
+ private static final BsonDocument EMPTY_VALUES = new BsonDocument();
+
+ private final BsonDocument values;
+ private final PathResolver pathResolver;
+
+ private final BsonDocument result = new BsonDocument();
+ private final EnumSet<Clause> seenClauses = EnumSet.noneOf(Clause.class);
+ private final Set<String> allPaths = new HashSet<>();
+
+ BsonEmittingVisitor(BsonDocument values, Map<String, String> aliases) {
+ this.values = values == null ? EMPTY_VALUES : values;
+ this.pathResolver = new PathResolver(aliases);
+ }
+
+ BsonDocument emit(UpdateExpressionContext ctx) {
+ for (org.antlr.v4.runtime.tree.ParseTree child : ctx.children) {
+ if (child instanceof SetClauseContext) {
+ visitSet((SetClauseContext) child);
+ } else if (child instanceof RemoveClauseContext) {
+ visitRemove((RemoveClauseContext) child);
+ } else if (child instanceof AddClauseContext) {
+ visitAdd((AddClauseContext) child);
+ } else if (child instanceof DeleteClauseContext) {
+ visitDelete((DeleteClauseContext) child);
+ }
+ }
+ return result;
+ }
+
+ // --- clauses ---
+
+ private void visitSet(SetClauseContext ctx) {
+ markClauseSeen(Clause.SET);
+ BsonDocument setDoc = subDoc("$SET");
+ for (SetActionContext action : ctx.setAction()) {
+ String key = pathResolver.resolve(action.path());
+ recordPath(key, Clause.SET);
+ setDoc.put(key, evaluateSetValue(action.setValue()));
+ }
+ }
+
+ private void visitRemove(RemoveClauseContext ctx) {
+ markClauseSeen(Clause.REMOVE);
+ BsonDocument unsetDoc = subDoc("$UNSET");
+ for (RemoveActionContext action : ctx.removeAction()) {
+ String key = pathResolver.resolve(action.path());
+ recordPath(key, Clause.REMOVE);
+ unsetDoc.put(key, BsonNull.VALUE);
+ }
+ }
+
+ private void visitAdd(AddClauseContext ctx) {
+ markClauseSeen(Clause.ADD);
+ BsonDocument addDoc = subDoc("$ADD");
+ for (AddActionContext action : ctx.addAction()) {
+ String key = pathResolver.resolve(action.path());
+ recordPath(key, Clause.ADD);
+ addDoc.put(key,
resolvePlaceholderToken(action.placeholder().getText()));
+ }
+ }
+
+ private void visitDelete(DeleteClauseContext ctx) {
+ markClauseSeen(Clause.DELETE);
+ BsonDocument delDoc = subDoc("$DELETE_FROM_SET");
+ for (DeleteActionContext action : ctx.deleteAction()) {
+ String key = pathResolver.resolve(action.path());
+ recordPath(key, Clause.DELETE);
+ delDoc.put(key,
resolvePlaceholderToken(action.placeholder().getText()));
+ }
+ }
+
+ // --- SET value (operand | operand + operand | operand - operand) ---
+
+ private BsonValue evaluateSetValue(
+ UpdateExpressionParser.SetValueContext ctx) {
+ if (ctx instanceof SetValueOperandContext) {
+ return evaluateOperand(((SetValueOperandContext) ctx).operand());
+ }
+ if (ctx instanceof SetValueAddContext) {
+ SetValueAddContext add = (SetValueAddContext) ctx;
+ return arithmeticDoc("$ADD", add.operand(0), add.operand(1));
+ }
+ if (ctx instanceof SetValueSubtractContext) {
+ SetValueSubtractContext sub = (SetValueSubtractContext) ctx;
+ return arithmeticDoc("$SUBTRACT", sub.operand(0), sub.operand(1));
+ }
+ throw new UpdateExpressionSyntaxException("Unsupported SET value: " +
ctx.getText());
+ }
+
+ private BsonValue arithmeticDoc(String op, OperandContext left,
OperandContext right) {
+ BsonArray operands = new BsonArray();
+ operands.add(arithmeticOperand(left));
+ operands.add(arithmeticOperand(right));
+ BsonDocument doc = new BsonDocument();
+ doc.put(op, operands);
+ return doc;
+ }
+
+ private BsonValue arithmeticOperand(OperandContext ctx) {
+ if (ctx instanceof OperandPathContext) {
+ return new BsonString(pathResolver.resolve(((OperandPathContext)
ctx).path()));
+ }
+ if (ctx instanceof OperandPlaceholderContext) {
+ String token = ((OperandPlaceholderContext)
ctx).placeholder().getText();
+ BsonValue v = resolvePlaceholderToken(token);
+ if (!v.isNumber() && !v.isDecimal128()) {
+ throw new UpdateExpressionSyntaxException(
+ "Operand " + token + " is not provided as number
type");
+ }
+ return v;
+ }
+ if (ctx instanceof OperandIfNotExistsContext) {
+ IfNotExistsContext inex = ((OperandIfNotExistsContext)
ctx).ifNotExists();
+ // If the fallback is a placeholder, anchor the numeric type-check
at the parser
+ // layer so the 400 points at the actual offending token (mirrors
the bare-placeholder
+ // branch above). Path fallbacks are resolved at runtime by the
BSON evaluator.
+ if (inex.ifNotExistsValue() instanceof
IfNotExistsValuePlaceholderContext) {
+ String token = ((IfNotExistsValuePlaceholderContext)
inex.ifNotExistsValue())
+ .placeholder().getText();
+ BsonValue v = resolvePlaceholderToken(token);
+ if (!v.isNumber() && !v.isDecimal128()) {
+ throw new UpdateExpressionSyntaxException(
+ "Operand " + token + " is not provided as number
type");
+ }
+ }
+ return ifNotExistsDoc(inex);
+ }
+ throw new UpdateExpressionSyntaxException(
+ "list_append is not a valid operand for arithmetic: " +
ctx.getText());
+ }
+
+ // --- generic operand for plain SET (no arithmetic) ---
+
+ private BsonValue evaluateOperand(OperandContext ctx) {
+ if (ctx instanceof OperandPathContext) {
+ return new BsonString(pathResolver.resolve(((OperandPathContext)
ctx).path()));
+ }
+ if (ctx instanceof OperandPlaceholderContext) {
+ return resolvePlaceholderToken(
+ ((OperandPlaceholderContext) ctx).placeholder().getText());
+ }
+ if (ctx instanceof OperandIfNotExistsContext) {
+ return ifNotExistsDoc(((OperandIfNotExistsContext)
ctx).ifNotExists());
+ }
+ if (ctx instanceof OperandListAppendContext) {
+ return listAppendDoc(((OperandListAppendContext)
ctx).listAppend());
+ }
+ throw new UpdateExpressionSyntaxException("Unsupported operand: " +
ctx.getText());
+ }
+
+ // --- functions ---
+
+ private BsonDocument ifNotExistsDoc(IfNotExistsContext ctx) {
+ String fieldKey = pathResolver.resolve(ctx.path());
+ BsonValue fallback;
+ if (ctx.ifNotExistsValue() instanceof IfNotExistsValuePathContext) {
+ fallback = new BsonString(pathResolver.resolve(
+ ((IfNotExistsValuePathContext)
ctx.ifNotExistsValue()).path()));
+ } else {
+ String token = ((IfNotExistsValuePlaceholderContext)
ctx.ifNotExistsValue())
+ .placeholder().getText();
+ fallback = resolvePlaceholderToken(token);
+ }
+ BsonDocument inner = new BsonDocument();
+ inner.put(fieldKey, fallback);
+ BsonDocument doc = new BsonDocument();
+ doc.put("$IF_NOT_EXISTS", inner);
+ return doc;
+ }
+
+ private BsonDocument listAppendDoc(ListAppendContext ctx) {
+ BsonArray operands = new BsonArray();
+ operands.add(listAppendOperand(ctx.listAppendOperand(0)));
+ operands.add(listAppendOperand(ctx.listAppendOperand(1)));
+ BsonDocument doc = new BsonDocument();
+ doc.put("$LIST_APPEND", operands);
+ return doc;
+ }
+
+ private BsonValue listAppendOperand(ListAppendOperandContext ctx) {
+ if (ctx instanceof ListAppendOperandPathContext) {
+ return new BsonString(pathResolver.resolve(
+ ((ListAppendOperandPathContext) ctx).path()));
+ }
+ if (ctx instanceof ListAppendOperandPlaceholderContext) {
+ String token = ((ListAppendOperandPlaceholderContext)
ctx).placeholder().getText();
+ BsonValue v = resolvePlaceholderToken(token);
+ if (!v.isArray()) {
+ throw new UpdateExpressionSyntaxException(
+ "Operand " + token + " for list_append must resolve to
a List type");
+ }
+ return v;
+ }
+ // ListAppendOperandIfNotExistsContext: validate that the fallback is
list-typed.
+ BsonDocument doc = ifNotExistsDoc(
+ ((ListAppendOperandIfNotExistsContext) ctx).ifNotExists());
+ BsonValue fallback =
doc.getDocument("$IF_NOT_EXISTS").values().iterator().next();
+ if (fallback != null && !fallback.isNull() && !fallback.isArray()) {
+ throw new UpdateExpressionSyntaxException(
+ "if_not_exists fallback inside list_append must resolve to
a List type"
+ + " but got: " + ctx.getText());
+ }
+ return doc;
+ }
+
+ // --- helpers ---
+
+ private BsonValue resolvePlaceholderToken(String token) {
+ BsonValue v = values.get(token);
+ if (v == null) {
+ throw new UpdateExpressionSyntaxException(
+ "Expression attribute value " + token
+ + " not declared in ExpressionAttributeValues");
+ }
+ return v;
+ }
+
+ private void markClauseSeen(Clause clause) {
+ if (!seenClauses.add(clause)) {
+ throw new UpdateExpressionSyntaxException(
+ "Clause keyword " + clause + " appears more than once in
UpdateExpression");
+ }
+ }
+
+ private void recordPath(String path, Clause clause) {
+ for (String existing : allPaths) {
+ if (PathResolver.pathsOverlap(existing, path)) {
+ throw new UpdateExpressionSyntaxException(
+ "Two document paths overlap in UpdateExpression "
+ + clause + " clause: '" + existing + "' and '"
+ path + "'");
+ }
+ }
+ allPaths.add(path);
+ }
+
+ private BsonDocument subDoc(String key) {
+ // markClauseSeen guarantees each clause is visited at most once, so
result.get(key)
+ // is null at this call site.
+ BsonDocument fresh = new BsonDocument();
+ result.put(key, fresh);
+ return fresh;
+ }
+}
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/PathResolver.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/PathResolver.java
new file mode 100644
index 0000000..63e2656
--- /dev/null
+++
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/PathResolver.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.phoenix.ddb.update;
+
+import java.util.Map;
+
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.PathContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.PathStepContext;
+import org.apache.phoenix.ddb.update.UpdateExpressionParser.PathSuffixContext;
+
+/**
+ * Builds the dotted-and-bracketed BSON key string from a parsed {@code path}
node, expanding
+ * {@code #alias} tokens against ExpressionAttributeNames. Also exposes the
overlap check used
+ * by the visitor to enforce DDB's "two document paths overlap" rule across
all clauses.
+ */
+final class PathResolver {
+
+ private final Map<String, String> aliases;
+
+ PathResolver(Map<String, String> aliases) {
+ this.aliases = aliases;
+ }
+
+ String resolve(PathContext ctx) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(resolveStep(ctx.pathStep()));
+ for (PathSuffixContext suffix : ctx.pathSuffix()) {
+ if (suffix.pathStep() != null) {
+ sb.append('.').append(resolveStep(suffix.pathStep()));
+ } else {
+ sb.append('[').append(suffix.INT().getText()).append(']');
+ }
+ }
+ return sb.toString();
+ }
+
+ private String resolveStep(PathStepContext step) {
+ if (step.ALIAS() != null) {
+ String alias = step.ALIAS().getText();
+ String resolved = aliases == null ? null : aliases.get(alias);
+ if (resolved == null) {
+ throw new UpdateExpressionSyntaxException(
+ "Expression attribute name " + alias
+ + " not declared in ExpressionAttributeNames");
+ }
+ return resolved;
+ }
+ return step.NAME().getText();
+ }
+
+ /** True if {@code a} == {@code b} or one is a strict prefix of the other
on a path boundary. */
+ static boolean pathsOverlap(String a, String b) {
+ if (a.equals(b)) {
+ return true;
+ }
+ return isStrictPrefix(a, b) || isStrictPrefix(b, a);
+ }
+
+ private static boolean isStrictPrefix(String shorter, String longer) {
+ if (longer.length() <= shorter.length() ||
!longer.startsWith(shorter)) {
+ return false;
+ }
+ char boundary = longer.charAt(shorter.length());
+ return boundary == '.' || boundary == '[';
+ }
+}
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionSyntaxException.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionSyntaxException.java
new file mode 100644
index 0000000..73edd52
--- /dev/null
+++
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionSyntaxException.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.phoenix.ddb.update;
+
+/**
+ * Thrown for syntactic or semantic errors in a DynamoDB UpdateExpression.
Extends
+ * {@link IllegalArgumentException} so callers (notably {@code
UpdateItemService}) keep mapping
+ * the failure to {@code ValidationException} and ultimately HTTP 400.
+ */
+public class UpdateExpressionSyntaxException extends IllegalArgumentException {
+
+ public UpdateExpressionSyntaxException(String message) {
+ super(message);
+ }
+}
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionToBson.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionToBson.java
new file mode 100644
index 0000000..3341f69
--- /dev/null
+++
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/update/UpdateExpressionToBson.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.phoenix.ddb.update;
+
+import java.util.Map;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.bson.BsonDocument;
+
+/**
+ * ANTLR-driven entry point: translates a DynamoDB UpdateExpression string
into the BSON
+ * document shape consumed by {@code UpdateExpressionUtils} server-side.
ExpressionAttributeNames
+ * and ExpressionAttributeValues are resolved at AST-walk time so {@code
#alias} tokens never
+ * collide with clause keywords.
+ */
+public final class UpdateExpressionToBson {
+
+ private UpdateExpressionToBson() {
+ }
+
+ public static BsonDocument toBsonUpdateDocument(String updateExpression,
+ BsonDocument
expressionAttributeValues) {
+ return toBsonUpdateDocument(updateExpression,
expressionAttributeValues, null);
+ }
+
+ public static BsonDocument toBsonUpdateDocument(String updateExpression,
+ BsonDocument
expressionAttributeValues,
+ Map<String, String>
expressionAttributeNames) {
+ if (updateExpression == null || updateExpression.trim().isEmpty()) {
+ throw new UpdateExpressionSyntaxException(
+ "UpdateExpression must contain at least one of
SET/REMOVE/ADD/DELETE");
+ }
+
+ UpdateExpressionLexer lexer = new
UpdateExpressionLexer(CharStreams.fromString(updateExpression));
+ lexer.removeErrorListeners();
+ lexer.addErrorListener(ThrowingErrorListener.INSTANCE);
+
+ UpdateExpressionParser parser = new UpdateExpressionParser(new
CommonTokenStream(lexer));
+ parser.removeErrorListeners();
+ // ThrowingErrorListener throws UpdateExpressionSyntaxException
directly with position
+ // info; rely on the default error strategy so the listener actually
fires.
+ parser.addErrorListener(ThrowingErrorListener.INSTANCE);
+
+ return new BsonEmittingVisitor(expressionAttributeValues,
expressionAttributeNames)
+ .emit(parser.updateExpression());
+ }
+
+ private static final class ThrowingErrorListener extends BaseErrorListener
{
+ static final ThrowingErrorListener INSTANCE = new
ThrowingErrorListener();
+
+ @Override
+ public void syntaxError(Recognizer<?, ?> recognizer, Object
offendingSymbol,
+ int line, int charPositionInLine,
+ String msg, RecognitionException e) {
+ throw new UpdateExpressionSyntaxException(
+ "Invalid UpdateExpression at position " +
charPositionInLine + ": " + msg);
+ }
+ }
+}
diff --git
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
index 7c3cfd0..b2027e9 100644
---
a/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
+++
b/phoenix-ddb-utils/src/main/java/org/apache/phoenix/ddb/utils/CommonServiceUtils.java
@@ -24,9 +24,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
-import org.apache.commons.lang3.StringUtils;
import org.apache.phoenix.ddb.bson.MapToBsonDocument;
-import org.apache.phoenix.ddb.bson.UpdateExpressionDdbToBson;
+import org.apache.phoenix.ddb.update.UpdateExpressionToBson;
import org.apache.phoenix.schema.PColumn;
import org.apache.phoenix.schema.types.PDataType;
import org.apache.phoenix.schema.types.PDecimal;
@@ -152,11 +151,8 @@ public class CommonServiceUtils {
*/
public static BsonDocument getBsonUpdateExpressionFromMap(String
updateExpr,
Map<String, String> exprAttrNames, Map<String, Object>
exprAttrVals) {
- if (StringUtils.isEmpty(updateExpr))
- return new BsonDocument();
- updateExpr = replaceExpressionAttributeNames(updateExpr,
exprAttrNames);
- return
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(updateExpr,
- MapToBsonDocument.getBsonDocument(exprAttrVals));
+ return UpdateExpressionToBson.toBsonUpdateDocument(updateExpr,
+ MapToBsonDocument.getBsonDocument(exprAttrVals),
exprAttrNames);
}
/**
diff --git a/pom.xml b/pom.xml
index 72c6859..32a7b3a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -58,6 +58,7 @@
<test.output.tofile>true</test.output.tofile>
<numForkedIT>1</numForkedIT>
<surefire.version>3.0.0-M6</surefire.version>
+ <antlr4.version>4.9.3</antlr4.version>
</properties>
<build>
@@ -153,6 +154,11 @@
<artifactId>phoenix-ddb-rest</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.antlr</groupId>
+ <artifactId>antlr4-runtime</artifactId>
+ <version>${antlr4.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>