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>
 

Reply via email to