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 a8674ee  Support list_append in UpdateExpression SET
a8674ee is described below

commit a8674ee27e3763bee54f909d458649da05e724d8
Author: Palash Chauhan <[email protected]>
AuthorDate: Wed May 13 10:52:17 2026 -0700

    Support list_append in UpdateExpression SET
---
 DDB_API_REFERENCE.md                               |   9 +
 .../ddb/UpdateExpressionConversionTest.java        | 417 +++++++++++++++++++++
 .../phoenix/ddb/UpdateExpressionValidationIT.java  | 147 ++++++++
 .../apache/phoenix/ddb/UpdateItemBaseTests.java    | 233 ++++++++++++
 .../ddb/bson/UpdateExpressionDdbToBson.java        |  95 ++++-
 5 files changed, 897 insertions(+), 4 deletions(-)

diff --git a/DDB_API_REFERENCE.md b/DDB_API_REFERENCE.md
index 1916fb1..375ef15 100644
--- a/DDB_API_REFERENCE.md
+++ b/DDB_API_REFERENCE.md
@@ -899,6 +899,10 @@ Modifies specific attributes of an existing item (or 
creates it if using `SET` o
 **UpdateExpression syntax:**
 ```
 SET #name = :newName, age = :newAge
+SET counter = counter + :increment, score = score - :penalty
+SET title = if_not_exists(title, :defaultTitle)
+SET events = list_append(events, :newEvents)
+SET queue = list_append(if_not_exists(queue, :empty), :newItems)
 REMOVE obsolete_field
 ADD view_count :increment
 DELETE tags :tagsToRemove
@@ -910,6 +914,11 @@ Supported clauses:
 - `ADD` -- Add to number or add elements to a set
 - `DELETE` -- Remove elements from a set
 
+Supported `SET` functions and operators:
+- `+` / `-` -- arithmetic on numeric attributes (e.g. `counter = counter + :n`)
+- `if_not_exists(path, :fallback)` -- use existing value if present, otherwise 
fall back
+- `list_append(operand1, operand2)` -- concatenate two lists. Each operand may 
be a literal list placeholder (e.g. `:newItems`), an attribute path (e.g. 
`events`, `nested.queue`), or `if_not_exists(path, :emptyList)`. Both operands 
must resolve to a list; exactly two operands are required; nested 
`list_append(list_append(...), ...)` is not supported.
+
 **Legacy AttributeUpdates format:**
 ```json
 {
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 87142c3..a5c73af 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
@@ -17,6 +17,7 @@
  */
 package org.apache.phoenix.ddb;
 
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -246,6 +247,422 @@ public class UpdateExpressionConversionTest {
                     getComparisonValuesMap()));
   }
 
+  @Test
+  public void testListAppendPathAndLiteral() {
+    String ddbUpdateExp = "SET myList = list_append(myList, :listVal)";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"myList\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        \"myList\",\n" +
+            "        [\"c\", \"d\"]\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  @Test
+  public void testListAppendLiteralAndPath() {
+    String ddbUpdateExp = "SET myList = list_append(:listVal, myList)";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"myList\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        [\"c\", \"d\"],\n" +
+            "        \"myList\"\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  @Test
+  public void testListAppendBothLiterals() {
+    String ddbUpdateExp = "SET myList = list_append(:listVal, :listVal2)";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"myList\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        [\"c\", \"d\"],\n" +
+            "        [\"e\", \"f\"]\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * Canonical create-or-append: if the target list does not exist, fall back 
to an empty
+   * list as the first operand and then append the new literal items.
+   */
+  @Test
+  public void testListAppendWithIfNotExists() {
+    String ddbUpdateExp = "SET newQueue = list_append(if_not_exists(newQueue, 
:emptyList), :listVal)";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"newQueue\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        {\n" +
+            "          \"$IF_NOT_EXISTS\": {\n" +
+            "            \"newQueue\": []\n" +
+            "          }\n" +
+            "        },\n" +
+            "        [\"c\", \"d\"]\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * list_append combined with arithmetic and a remove in the same expression.
+   */
+  @Test
+  public void testListAppendMixedWithArithmeticAndRemove() {
+    String ddbUpdateExp =
+            "SET myList = list_append(myList, :listVal), counter = counter + 
:one "
+                    + "REMOVE oldField";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"myList\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        \"myList\",\n" +
+            "        [\"c\", \"d\"]\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"counter\": {\n" +
+            "      \"$ADD\": [\n" +
+            "        \"counter\",\n" +
+            "        1\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  },\n" +
+            "  \"$UNSET\": {\n" +
+            "    \"oldField\": null\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * Nested document path on the LHS and as the path operand.
+   */
+  @Test
+  public void testListAppendNestedPath() {
+    String ddbUpdateExp = "SET nested.queue = list_append(nested.queue, 
:listVal)";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"nested.queue\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        \"nested.queue\",\n" +
+            "        [\"c\", \"d\"]\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * if_not_exists as the SECOND operand (literal first). This prepends the 
new items
+   * before the existing-or-fallback list.
+   */
+  @Test
+  public void testListAppendLiteralAndIfNotExists() {
+    String ddbUpdateExp = "SET newQueue = list_append(:listVal, 
if_not_exists(newQueue, :emptyList))";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"newQueue\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        [\"c\", \"d\"],\n" +
+            "        {\n" +
+            "          \"$IF_NOT_EXISTS\": {\n" +
+            "            \"newQueue\": []\n" +
+            "          }\n" +
+            "        }\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * Create-or-append a list while atomically incrementing a counter in the 
same SET clause.
+   */
+  @Test
+  public void testListAppendWithIfNotExistsAndCounterIncrement() {
+    String ddbUpdateExp =
+            "SET events = list_append(if_not_exists(events, :empty_list), 
:new_evts), "
+                    + "updateCounter = updateCounter + :one";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"events\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        {\n" +
+            "          \"$IF_NOT_EXISTS\": {\n" +
+            "            \"events\": []\n" +
+            "          }\n" +
+            "        },\n" +
+            "        [\"ev1\", \"ev2\"]\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"updateCounter\": {\n" +
+            "      \"$ADD\": [\n" +
+            "        \"updateCounter\",\n" +
+            "        1\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Map<String, AttributeValue> attributeMap = new HashMap<>();
+    attributeMap.put(":empty_list",
+            AttributeValue.builder().l(Collections.emptyList()).build());
+    attributeMap.put(":new_evts", AttributeValue.builder().l(
+            AttributeValue.builder().s("ev1").build(),
+            AttributeValue.builder().s("ev2").build()).build());
+    attributeMap.put(":one", AttributeValue.builder().n("1").build());
+    BsonDocument values = 
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp, 
values));
+  }
+
+  /**
+   * Both operands of list_append are if_not_exists. Regression test for the
+   * paren-counting splitter: there are top-level commas at depth 1 followed by
+   * another open-paren on each side, which the old regex-based splitter could 
not
+   * handle.
+   */
+  @Test
+  public void testListAppendBothOperandsAreIfNotExists() {
+    String ddbUpdateExp =
+            "SET merged = list_append(if_not_exists(left, :emptyList), 
if_not_exists(right, :emptyList))";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"merged\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        { \"$IF_NOT_EXISTS\": { \"left\":  [] } },\n" +
+            "        { \"$IF_NOT_EXISTS\": { \"right\": [] } }\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    getListAppendComparisonValuesMap()));
+  }
+
+  /**
+   * Canonical counter-init pattern: increment a counter that may not exist 
yet, using
+   * if_not_exists as one of the arithmetic operands. Common DDB usage but 
previously
+   * only covered by IT tests.
+   */
+  @Test
+  public void testArithmeticWithIfNotExistsCounterIncrement() {
+    String ddbUpdateExp = "SET counter = if_not_exists(counter, :zero) + :one";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"counter\": {\n" +
+            "      \"$ADD\": [\n" +
+            "        { \"$IF_NOT_EXISTS\": { \"counter\": 0 } },\n" +
+            "        1\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),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp, 
values));
+  }
+
+  /**
+   * Arithmetic with both operands being attribute paths (no placeholders, no
+   * if_not_exists).
+   */
+  @Test
+  public void testArithmeticPathPlusPath() {
+    String ddbUpdateExp = "SET total = subtotal + tax";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"total\": {\n" +
+            "      \"$ADD\": [\n" +
+            "        \"subtotal\",\n" +
+            "        \"tax\"\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+                    new BsonDocument()));
+  }
+
+  /**
+   * Subtract with placeholder first and attribute path second
+   * (e.g. {@code SET remaining = :limit - used}).
+   */
+  @Test
+  public void testArithmeticLiteralMinusPath() {
+    String ddbUpdateExp = "SET remaining = :limit - used";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"remaining\": {\n" +
+            "      \"$SUBTRACT\": [\n" +
+            "        100,\n" +
+            "        \"used\"\n" +
+            "      ]\n" +
+            "    }\n" +
+            "  }\n" +
+            "}";
+    Map<String, AttributeValue> attributeMap = new HashMap<>();
+    attributeMap.put(":limit", AttributeValue.builder().n("100").build());
+    BsonDocument values = 
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp, 
values));
+  }
+
+  /**
+   * Multiple SET clauses interleaving every supported function/operator shape:
+   * plain placeholder, if_not_exists, arithmetic with if_not_exists, 
list_append,
+   * and a bare nested-path assignment.
+   */
+  @Test
+  public void testSetEveryShapeMixed() {
+    String ddbUpdateExp = "SET title = :newTitle, "
+            + "counter = if_not_exists(counter, :zero) + :one, "
+            + "total = price - :discount, "
+            + "events = list_append(if_not_exists(events, :emptyList), 
:newEvts), "
+            + "nested.path = :nestedVal";
+    String expected = "{\n" +
+            "  \"$SET\": {\n" +
+            "    \"title\": \"hello\",\n" +
+            "    \"counter\": {\n" +
+            "      \"$ADD\": [\n" +
+            "        { \"$IF_NOT_EXISTS\": { \"counter\": 0 } },\n" +
+            "        1\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"total\": {\n" +
+            "      \"$SUBTRACT\": [\n" +
+            "        \"price\",\n" +
+            "        5\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"events\": {\n" +
+            "      \"$LIST_APPEND\": [\n" +
+            "        { \"$IF_NOT_EXISTS\": { \"events\": [] } },\n" +
+            "        [\"e1\", \"e2\"]\n" +
+            "      ]\n" +
+            "    },\n" +
+            "    \"nested.path\": \"deep\"\n" +
+            "  }\n" +
+            "}";
+    Map<String, AttributeValue> attributeMap = new HashMap<>();
+    attributeMap.put(":newTitle", AttributeValue.builder().s("hello").build());
+    attributeMap.put(":zero", AttributeValue.builder().n("0").build());
+    attributeMap.put(":one", AttributeValue.builder().n("1").build());
+    attributeMap.put(":discount", AttributeValue.builder().n("5").build());
+    attributeMap.put(":emptyList",
+            AttributeValue.builder().l(Collections.emptyList()).build());
+    attributeMap.put(":newEvts", AttributeValue.builder().l(
+            AttributeValue.builder().s("e1").build(),
+            AttributeValue.builder().s("e2").build()).build());
+    attributeMap.put(":nestedVal", AttributeValue.builder().s("deep").build());
+    BsonDocument values = 
DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+    Assert.assertEquals(RawBsonDocument.parse(expected),
+            
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp, 
values));
+  }
+
+  @Test
+  public void testListAppendWrongArityOne() {
+    String ddbUpdateExp = "SET myList = list_append(:listVal)";
+    try {
+      
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+              getListAppendComparisonValuesMap());
+      Assert.fail("Expected RuntimeException for wrong arity (1 operand)");
+    } catch (RuntimeException e) {
+      Assert.assertTrue("Unexpected message: " + e.getMessage(),
+              e.getMessage().contains("list_append requires exactly 2 
operands"));
+    }
+  }
+
+  @Test
+  public void testListAppendWrongArityThree() {
+    String ddbUpdateExp = "SET myList = list_append(:listVal, :listVal2, 
myList)";
+    try {
+      
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+              getListAppendComparisonValuesMap());
+      Assert.fail("Expected RuntimeException for wrong arity (3 operands)");
+    } catch (RuntimeException e) {
+      Assert.assertTrue("Unexpected message: " + e.getMessage(),
+              e.getMessage().contains("list_append requires exactly 2 
operands"));
+    }
+  }
+
+  @Test
+  public void testListAppendMissingPlaceholder() {
+    String ddbUpdateExp = "SET myList = list_append(myList, :missing)";
+    try {
+      
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+              getListAppendComparisonValuesMap());
+      Assert.fail("Expected RuntimeException for missing placeholder");
+    } catch (RuntimeException e) {
+      Assert.assertTrue("Unexpected message: " + e.getMessage(),
+              e.getMessage().contains(":missing"));
+    }
+  }
+
+  @Test
+  public void testListAppendNonArrayPlaceholder() {
+    String ddbUpdateExp = "SET myList = list_append(myList, :notList)";
+    try {
+      
UpdateExpressionDdbToBson.getBsonDocumentForUpdateExpression(ddbUpdateExp,
+              getListAppendComparisonValuesMap());
+      Assert.fail("Expected RuntimeException for non-list placeholder");
+    } catch (RuntimeException e) {
+      Assert.assertTrue("Unexpected message: " + e.getMessage(),
+              e.getMessage().contains("must resolve to a List type"));
+    }
+  }
+
+  private static BsonDocument getListAppendComparisonValuesMap() {
+    Map<String, AttributeValue> attributeMap = new HashMap<>();
+    attributeMap.put(":listVal", AttributeValue.builder().l(
+            AttributeValue.builder().s("c").build(),
+            AttributeValue.builder().s("d").build()).build());
+    attributeMap.put(":listVal2", AttributeValue.builder().l(
+            AttributeValue.builder().s("e").build(),
+            AttributeValue.builder().s("f").build()).build());
+    attributeMap.put(":emptyList",
+            AttributeValue.builder().l(Collections.emptyList()).build());
+    attributeMap.put(":one", AttributeValue.builder().n("1").build());
+    attributeMap.put(":notList", AttributeValue.builder().s("scalar").build());
+    return DdbAttributesToBsonDocument.getRawBsonDocument(attributeMap);
+  }
+
   private static BsonDocument getComparisonValuesMap() {
     Map<String, AttributeValue> attributeMap = new HashMap<>();
     attributeMap.put(":aCol", AttributeValue.builder().ns("1", "2", 
"3").build());
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 e874125..c3752e1 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
@@ -529,6 +529,153 @@ public class UpdateExpressionValidationIT {
                 "DELETE from set same type");
     }
 
+    // list_append Operation Tests
+
+    /**
+     * list_append against an existing list attribute path with a literal list 
operand.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendExistingList() {
+        Map<String, AttributeValue> item = getKey();
+        item.put("a", AttributeValue.builder()
+                .l(AttributeValue.builder().s("x").build()).build());
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":val", AttributeValue.builder().l(
+                AttributeValue.builder().s("y").build(),
+                AttributeValue.builder().s("z").build()).build());
+
+        testUpdateExpressionSuccess(tableName, "SET a = list_append(a, :val)", 
null,
+                expressionAttributeValues, "list_append append literal to 
existing list");
+    }
+
+    /**
+     * list_append with if_not_exists fallback when the target attribute is 
missing.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendIfNotExistsMissing() {
+        Map<String, AttributeValue> item = getKey();
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":empty",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        expressionAttributeValues.put(":val", AttributeValue.builder().l(
+                AttributeValue.builder().s("y").build()).build());
+
+        testUpdateExpressionSuccess(tableName,
+                "SET a = list_append(if_not_exists(a, :empty), :val)", null,
+                expressionAttributeValues, "list_append create-or-append on 
missing attribute");
+    }
+
+    /**
+     * if_not_exists as the SECOND operand (literal list first). This is the 
prepend
+     * variant of the canonical create-or-append pattern.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendLiteralAndIfNotExists() {
+        Map<String, AttributeValue> item = getKey();
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":empty",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        expressionAttributeValues.put(":val", AttributeValue.builder().l(
+                AttributeValue.builder().s("y").build()).build());
+
+        testUpdateExpressionSuccess(tableName,
+                "SET a = list_append(:val, if_not_exists(a, :empty))", null,
+                expressionAttributeValues, "list_append with literal first and 
if_not_exists second");
+    }
+
+    /**
+     * Both operands of list_append are literal value placeholders (no paths, 
no
+     * if_not_exists). Result should be the simple concatenation of the two 
literals.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendBothLiterals() {
+        Map<String, AttributeValue> item = getKey();
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":v1", AttributeValue.builder().l(
+                AttributeValue.builder().s("a").build(),
+                AttributeValue.builder().s("b").build()).build());
+        expressionAttributeValues.put(":v2", AttributeValue.builder().l(
+                AttributeValue.builder().s("c").build(),
+                AttributeValue.builder().s("d").build()).build());
+
+        testUpdateExpressionSuccess(tableName,
+                "SET combined = list_append(:v1, :v2)", null,
+                expressionAttributeValues, "list_append with two literal-list 
operands");
+    }
+
+    /**
+     * Both operands of list_append are if_not_exists.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendBothOperandsAreIfNotExists() {
+        // Seed item: 'leftList' exists, 'rightList' does not
+        Map<String, AttributeValue> item = getKey();
+        item.put("leftList", AttributeValue.builder()
+                .l(AttributeValue.builder().s("x").build()).build());
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":emptyLeft",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        expressionAttributeValues.put(":emptyRight",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+
+        testUpdateExpressionSuccess(tableName,
+                "SET merged = list_append("
+                        + "if_not_exists(leftList, :emptyLeft), "
+                        + "if_not_exists(rightList, :emptyRight))",
+                null, expressionAttributeValues,
+                "list_append with if_not_exists in both operand positions");
+    }
+
+    /**
+     * Nested list_append (list_append inside list_append) is not part of AWS 
DynamoDB's
+     * documented UpdateExpression grammar -- the only function permitted as 
an operand is
+     * if_not_exists. Both real DDB and the phoenix-adapter must reject it 
with HTTP 400.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendNestedRejected() {
+        Map<String, AttributeValue> item = getKey();
+        item.put("a", AttributeValue.builder()
+                .l(AttributeValue.builder().s("x").build()).build());
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":v1", AttributeValue.builder().l(
+                AttributeValue.builder().s("y").build()).build());
+        expressionAttributeValues.put(":v2", AttributeValue.builder().l(
+                AttributeValue.builder().s("z").build()).build());
+
+        testUpdateExpressionFailure(tableName,
+                "SET a = list_append(list_append(a, :v1), :v2)", null,
+                expressionAttributeValues, "nested list_append should be 
rejected");
+    }
+
+    /**
+     * list_append where the target path resolves to a non-list value should 
fail.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendTargetNotList() {
+        Map<String, AttributeValue> item = getKey();
+        item.put("a", AttributeValue.builder().n("123").build());
+        putTestItem(tableName, item);
+
+        Map<String, AttributeValue> expressionAttributeValues = new 
HashMap<>();
+        expressionAttributeValues.put(":val", AttributeValue.builder().l(
+                AttributeValue.builder().s("y").build()).build());
+
+        testUpdateExpressionFailure(tableName, "SET a = list_append(a, :val)", 
null,
+                expressionAttributeValues, "list_append on attribute that is 
not a list");
+    }
+
     private void putTestItem(String tableName, Map<String, AttributeValue> 
item) {
         PutItemRequest putRequest =
                 
PutItemRequest.builder().tableName(tableName).item(item).build();
diff --git 
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemBaseTests.java
 
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemBaseTests.java
index e2ac382..e758688 100644
--- 
a/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemBaseTests.java
+++ 
b/phoenix-ddb-rest/src/test/java/org/apache/phoenix/ddb/UpdateItemBaseTests.java
@@ -210,6 +210,239 @@ public class UpdateItemBaseTests {
         validateItem(tableName, key);
     }
 
+    /**
+     * SET: list_append against an existing list attribute path, plus a 
parallel
+     * "create-or-append" using if_not_exists for a previously-missing list, 
plus a
+     * subsequent re-apply that exercises both the existing-path operand and a 
literal
+     * operand.
+     */
+    @Test(timeout = 120000)
+    public void testListAppend() {
+        final String tableName = 
testName.getMethodName().replaceAll("[\\[\\]]", "");
+        createTableAndPutItem(tableName, true);
+
+        Map<String, AttributeValue> key = getKey();
+
+        // First update: create NewList via if_not_exists, append literal 
items to it.
+        UpdateItemRequest.Builder uir1 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir1.updateExpression(
+                "SET #newList = list_append(if_not_exists(#newList, :empty), 
:items1)");
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#newList", "NewList");
+        uir1.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal1 = new HashMap<>();
+        exprAttrVal1.put(":empty", 
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        exprAttrVal1.put(":items1", AttributeValue.builder().l(
+                AttributeValue.builder().s("a").build(),
+                AttributeValue.builder().s("b").build()).build());
+        uir1.expressionAttributeValues(exprAttrVal1);
+        dynamoDbClient.updateItem(uir1.build());
+        phoenixDBClientV2.updateItem(uir1.build());
+
+        validateItem(tableName, key);
+
+        // Second update: append more items to the now-existing list (path 
operand + literal).
+        UpdateItemRequest.Builder uir2 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir2.updateExpression("SET #newList = list_append(#newList, :items2)");
+        uir2.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal2 = new HashMap<>();
+        exprAttrVal2.put(":items2", AttributeValue.builder().l(
+                AttributeValue.builder().s("c").build(),
+                AttributeValue.builder().s("d").build()).build());
+        uir2.expressionAttributeValues(exprAttrVal2);
+        dynamoDbClient.updateItem(uir2.build());
+        phoenixDBClientV2.updateItem(uir2.build());
+
+        validateItem(tableName, key);
+
+        // Third update: prepend (literal first, path second).
+        // After this, the final list order is [z, a, b, c, d] — the literal 
is prepended.
+        UpdateItemRequest.Builder uir3 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir3.updateExpression("SET #newList = list_append(:items3, #newList)");
+        uir3.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal3 = new HashMap<>();
+        exprAttrVal3.put(":items3", AttributeValue.builder().l(
+                AttributeValue.builder().s("z").build()).build());
+        uir3.expressionAttributeValues(exprAttrVal3);
+        dynamoDbClient.updateItem(uir3.build());
+        phoenixDBClientV2.updateItem(uir3.build());
+
+        validateItem(tableName, key);
+    }
+
+    /**
+     * Create-or-append where the same operation is invoked twice with an 
overlapping
+     * payload, leaving duplicates in the resulting list (list_append 
preserves order
+     * and does not de-duplicate).
+     */
+    @Test(timeout = 120000)
+    public void testListAppendCreateOrAppendWithDuplicates() {
+        final String tableName = 
testName.getMethodName().replaceAll("[\\[\\]]", "");
+        createTableAndPutItem(tableName, true);
+
+        Map<String, AttributeValue> key = getKey();
+
+        UpdateItemRequest.Builder uir = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir.updateExpression(
+                "SET #newList = list_append(if_not_exists(#newList, :empty), 
:items)");
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#newList", "NewList");
+        uir.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal = new HashMap<>();
+        exprAttrVal.put(":empty",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        // payload contains an internal duplicate ("a" twice) and is then 
re-applied to itself.
+        exprAttrVal.put(":items", AttributeValue.builder().l(
+                AttributeValue.builder().s("a").build(),
+                AttributeValue.builder().s("b").build(),
+                AttributeValue.builder().s("a").build()).build());
+        uir.expressionAttributeValues(exprAttrVal);
+
+        // First apply: NewList does not exist; create it via if_not_exists 
fallback and
+        // append the literal payload, yielding [a, b, a].
+        dynamoDbClient.updateItem(uir.build());
+        phoenixDBClientV2.updateItem(uir.build());
+        validateItem(tableName, key);
+
+        // Second apply: NewList now exists; append the same literal payload 
again,
+        // yielding [a, b, a, a, b, a]. Confirms list_append preserves 
duplicates and
+        // is not idempotent.
+        UpdateItemResponse dynamoResult = dynamoDbClient.updateItem(
+                uir.returnValues(ALL_NEW).build());
+        UpdateItemResponse phoenixResult = phoenixDBClientV2.updateItem(
+                uir.returnValues(ALL_NEW).build());
+        Assert.assertEquals(dynamoResult.attributes(), 
phoenixResult.attributes());
+
+        AttributeValue finalList = phoenixResult.attributes().get("NewList");
+        Assert.assertNotNull("NewList should be present in returned 
attributes", finalList);
+        Assert.assertEquals("list_append preserves order and duplicates",
+                java.util.Arrays.asList("a", "b", "a", "a", "b", "a"),
+                
finalList.l().stream().map(AttributeValue::s).collect(java.util.stream.Collectors.toList()));
+
+        validateItem(tableName, key);
+    }
+
+    /**
+     * Prepend variant: list_append with a literal list as the first operand 
and an
+     * if_not_exists fallback as the second operand. New items end up before 
the
+     * existing-or-fallback list.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendLiteralAndIfNotExists() {
+        final String tableName = 
testName.getMethodName().replaceAll("[\\[\\]]", "");
+        createTableAndPutItem(tableName, true);
+
+        Map<String, AttributeValue> key = getKey();
+
+        // First apply: NewList does not exist, so if_not_exists falls back to 
[]. The
+        // result is the literal :items prepended to that empty list -> [a, b].
+        UpdateItemRequest.Builder uir1 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir1.updateExpression(
+                "SET #newList = list_append(:items1, if_not_exists(#newList, 
:empty))");
+        Map<String, String> exprAttrNames = new HashMap<>();
+        exprAttrNames.put("#newList", "NewList");
+        uir1.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal1 = new HashMap<>();
+        exprAttrVal1.put(":empty",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        exprAttrVal1.put(":items1", AttributeValue.builder().l(
+                AttributeValue.builder().s("a").build(),
+                AttributeValue.builder().s("b").build()).build());
+        uir1.expressionAttributeValues(exprAttrVal1);
+        dynamoDbClient.updateItem(uir1.build());
+        phoenixDBClientV2.updateItem(uir1.build());
+        validateItem(tableName, key);
+
+        // Second apply: NewList now exists; literal :items2 is prepended in 
front of it.
+        // Result: [c, d, a, b].
+        UpdateItemRequest.Builder uir2 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir2.updateExpression(
+                "SET #newList = list_append(:items2, if_not_exists(#newList, 
:empty))");
+        uir2.expressionAttributeNames(exprAttrNames);
+        Map<String, AttributeValue> exprAttrVal2 = new HashMap<>();
+        exprAttrVal2.put(":empty",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        exprAttrVal2.put(":items2", AttributeValue.builder().l(
+                AttributeValue.builder().s("c").build(),
+                AttributeValue.builder().s("d").build()).build());
+        uir2.expressionAttributeValues(exprAttrVal2);
+        UpdateItemResponse dynamoResult = 
dynamoDbClient.updateItem(uir2.returnValues(ALL_NEW).build());
+        UpdateItemResponse phoenixResult = 
phoenixDBClientV2.updateItem(uir2.returnValues(ALL_NEW).build());
+        Assert.assertEquals(dynamoResult.attributes(), 
phoenixResult.attributes());
+
+        AttributeValue finalList = phoenixResult.attributes().get("NewList");
+        Assert.assertNotNull("NewList should be present in returned 
attributes", finalList);
+        Assert.assertEquals("literal-first list_append prepends to existing 
list",
+                java.util.Arrays.asList("c", "d", "a", "b"),
+                finalList.l().stream().map(AttributeValue::s)
+                        .collect(java.util.stream.Collectors.toList()));
+
+        validateItem(tableName, key);
+    }
+
+    /**
+     * Create-or-append a list while atomically incrementing a counter in the 
same SET
+     * clause, gated on an updateCounter condition expression.
+     */
+    @Test(timeout = 120000)
+    public void testListAppendWithIfNotExistsAndCounterIncrement() {
+        final String tableName = 
testName.getMethodName().replaceAll("[\\[\\]]", "");
+        createTableAndPutItem(tableName, true);
+
+        Map<String, AttributeValue> key = getKey();
+
+        // Iteration 1: events does not exist; updateCounter starts unset, so 
initialize it
+        // first via a plain SET so the condition expression can reference it 
deterministically.
+        UpdateItemRequest.Builder seed = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        seed.updateExpression("SET updateCounter = :zero");
+        Map<String, AttributeValue> seedVals = new HashMap<>();
+        seedVals.put(":zero", AttributeValue.builder().n("0").build());
+        seed.expressionAttributeValues(seedVals);
+        dynamoDbClient.updateItem(seed.build());
+        phoenixDBClientV2.updateItem(seed.build());
+
+        // Iteration 2: list_append(if_not_exists(events, :empty), :evts1) + 
counter increment,
+        // gated on updateCounter = 0.
+        UpdateItemRequest.Builder uir1 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        uir1.updateExpression(
+                "SET events = list_append(if_not_exists(events, :empty_list), 
:new_evts), "
+                        + "updateCounter = updateCounter + :one");
+        uir1.conditionExpression("updateCounter = :expected");
+        Map<String, AttributeValue> exprAttrVal1 = new HashMap<>();
+        exprAttrVal1.put(":empty_list",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        exprAttrVal1.put(":new_evts", AttributeValue.builder().l(
+                AttributeValue.builder().s("ev1").build(),
+                AttributeValue.builder().s("ev2").build()).build());
+        exprAttrVal1.put(":one", AttributeValue.builder().n("1").build());
+        exprAttrVal1.put(":expected", AttributeValue.builder().n("0").build());
+        uir1.expressionAttributeValues(exprAttrVal1);
+        dynamoDbClient.updateItem(uir1.build());
+        phoenixDBClientV2.updateItem(uir1.build());
+
+        validateItem(tableName, key);
+
+        // Iteration 3: append more events; gated on the post-iter-2 counter 
value.
+        UpdateItemRequest.Builder uir2 = 
UpdateItemRequest.builder().tableName(tableName).key(key);
+        // Extra whitespace around closing paren and comma to verify parser 
tolerance.
+        uir2.updateExpression(
+                "SET events = list_append(if_not_exists(events, :empty_list), 
:new_evts  )   , "
+                        + "updateCounter = updateCounter + :one");
+        uir2.conditionExpression("updateCounter = :expected");
+        Map<String, AttributeValue> exprAttrVal2 = new HashMap<>();
+        exprAttrVal2.put(":empty_list",
+                
AttributeValue.builder().l(java.util.Collections.emptyList()).build());
+        exprAttrVal2.put(":new_evts", AttributeValue.builder().l(
+                AttributeValue.builder().s("ev3").build()).build());
+        exprAttrVal2.put(":one", AttributeValue.builder().n("1").build());
+        exprAttrVal2.put(":expected", AttributeValue.builder().n("1").build());
+        uir2.expressionAttributeValues(exprAttrVal2);
+        dynamoDbClient.updateItem(uir2.build());
+        phoenixDBClientV2.updateItem(uir2.build());
+
+        validateItem(tableName, key);
+    }
+
     /**
      * REMOVE - Removes one or more attributes from an item.
      */
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
index 5db52d6..aa404b4 100644
--- 
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
@@ -37,11 +37,13 @@ public class UpdateExpressionDdbToBson {
   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,
@@ -75,15 +77,17 @@ public class UpdateExpressionDdbToBson {
     BsonDocument bsonDocument = new BsonDocument();
     if (!setString.isEmpty()) {
       BsonDocument setBsonDoc = new BsonDocument();
-      // split by comma only if comma is not within ()
-      String[] setExpressions = setString.split(",(?![^()]*+\\))");
+      // 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.contains("+") || attributeVal.contains("-")) {
+          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));
@@ -162,7 +166,7 @@ public class UpdateExpressionDdbToBson {
           operand = operand.trim();
           if (operand.startsWith("if_not_exists")) {
               bsonOperands.add(getIfNotExistsDoc(operand, comparisonValue));
-          } else if (operand.startsWith(":") || operand.startsWith("$") || 
operand.startsWith("#")) {
+          } else if (operand.startsWith(":")) {
               BsonValue bsonValue = comparisonValue.get(operand);
               if (!bsonValue.isNumber() && !bsonValue.isDecimal128()) {
                   throw new IllegalArgumentException(
@@ -192,4 +196,87 @@ public class UpdateExpressionDdbToBson {
           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]);
+  }
 }


Reply via email to