Repository: floci-io/floci (2026-07-19T21:30:03Z)

Issue:
[BUG] DynamoDB UpdateItem: ADD on a nested map path with ExpressionAttributeNames creates a phantom top-level attribute instead of incrementing
## Service

DynamoDB

## AWS API Action

UpdateItem

## Expected behavior

`ADD` on a nested document path using `ExpressionAttributeName` placeholders (e.g. `ADD #fis.#isa.#ea :one`) resolves the placeholders, traverses the map, and increments the nested numeric attribute — as real DynamoDB and LocalStack do.

## Actual behavior

The `#name` placeholders are never resolved and the dotted path is never traversed. The operation succeeds but creates a **new top-level attribute whose name is the literal expression string** (`"#fis.#isa.#ea"`) and adds the value there. The real nested counter is unchanged.

Notably, `SET ... = if_not_exists(...)` on the same style of nested placeholder path works correctly — the defect is scoped to the `ADD` action's path resolution. (Looks related in spirit to #402/#127, which fixed `REMOVE` on nested paths and `#name` resolution elsewhere.)

## Reproduction

```bash
#!/usr/bin/env bash
set -euo pipefail

ENDPOINT="http://localhost:4566"
AWS="aws dynamodb --endpoint-url $ENDPOINT --region us-east-1"
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test

$AWS create-table \
  --table-name add-nested-repro \
  --key-schema AttributeName=pk,KeyType=HASH \
  --attribute-definitions AttributeName=pk,AttributeType=S \
  --billing-mode PAY_PER_REQUEST
$AWS wait table-exists --table-name add-nested-repro

# Seed the nested map — SET + if_not_exists on nested placeholder paths works fine
$AWS update-item \
  --table-name add-nested-repro \
  --key '{"pk":{"S":"item1"}}' \
  --update-expression "SET #fis = if_not_exists(#fis, :empty)" \
  --expression-attribute-names '{"#fis":"functionInvocationSummaries"}' \
  --expression-attribute-values '{":empty":{"M":{}}}'

$AWS update-item \
  --table-name add-nested-repro \
  --key '{"pk":{"S":"item1"}}' \
  --update-expression "SET #fis.#isa = if_not_exists(#fis.#isa, :seed)" \
  --expression-attribute-names '{"#fis":"functionInvocationSummaries","#isa":"ISA_10136"}' \
  --expression-attribute-values '{":seed":{"M":{"eventsAvailable":{"N":"0"}}}}'

# BUG: ADD on the nested placeholder path
$AWS update-item \
  --table-name add-nested-repro \
  --key '{"pk":{"S":"item1"}}' \
  --update-expression "ADD #fis.#isa.#ea :one" \
  --expression-attribute-names '{"#fis":"functionInvocationSummaries","#isa":"ISA_10136","#ea":"eventsAvailable"}' \
  --expression-attribute-values '{":one":{"N":"1"}}'

$AWS get-item --table-name add-nested-repro --key '{"pk":{"S":"item1"}}' --consistent-read
```

Result on Floci 1.5.30:

```json
{
  "Item": {
    "pk": {"S": "item1"},
    "functionInvocationSummaries": {"M": {"ISA_10136": {"M": {"eventsAvailable": {"N": "0"}}}}},
    "#fis.#isa.#ea": {"N": "1"}
  }
}
```

Result on real DynamoDB / LocalStack (community v3), identical commands:

```json
{
  "Item": {
    "pk": {"S": "item1"},
    "functionInvocationSummaries": {"M": {"ISA_10136": {"M": {"eventsAvailable": {"N": "1"}}}}}
  }
}
```

Deterministic; reproduced on a fresh container. Found while migrating a test suite from LocalStack — this is the update pattern ElectroDB emits for counters in nested maps, so it likely affects other ElectroDB/document-client users too.

## Environment

- Floci version / image tag: `floci/floci:latest` = 1.5.30 (revision 6d9edfe)


Discussion:
Both prior fixes (#1757 and #1873) are now closed unmerged. I have a consolidated ADD + DELETE nested-path fix ready and will open it shortly. Please let me know if this is already in progress elsewhere.
Just commenting to say that we are also affected by this bug and that it hinders our adoption of Floci.



Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java b/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
index 10c15e0fcf..5d00b6dff9 100644
--- a/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbService.java
@@ -1710,15 +1710,15 @@ private String applyAddClause(ObjectNode item, String clause,
             String[] parts = clause.split("\\s+", 3);
             if (parts.length < 2) break;
 
-            String attrName = resolveAttributeName(parts[0], exprAttrNames);
+            String attrPath = parts[0];
             String valuePlaceholder = parts[1].replaceAll(",.*", "").trim();
 
             if (valuePlaceholder.startsWith(":") && exprAttrValues != null) {
                 JsonNode addValue = exprAttrValues.get(valuePlaceholder);
                 if (addValue != null) {
-                    JsonNode existingValue = item.get(attrName);
+                    JsonNode existingValue = getValueAtPath(item, attrPath, exprAttrNames);
                     JsonNode newValue = applyAddOperation(existingValue, addValue);
-                    item.set(attrName, newValue);
+                    setValueAtPath(item, attrPath, newValue, exprAttrNames);
                 }
             }
 
@@ -1821,19 +1821,19 @@ private String applyDeleteClause(ObjectNode item, String clause,
             String[] parts = clause.split("\\s+", 3);
             if (parts.length < 2) break;
 
-            String attrName = resolveAttributeName(parts[0], exprAttrNames);
+            String attrPath = parts[0];
             String valuePlaceholder = parts[1].replaceAll(",.*", "").trim();
 
             if (valuePlaceholder.startsWith(":") && exprAttrValues != null) {
                 JsonNode deleteValue = exprAttrValues.get(valuePlaceholder);
                 if (deleteValue != null) {
-                    JsonNode existingValue = item.get(attrName);
+                    JsonNode existingValue = getValueAtPath(item, attrPath, exprAttrNames);
                     if (existingValue != null) {
                         JsonNode newValue = applyDeleteOperation(existingValue, deleteValue);
                         if (newValue == null) {
-                            item.remove(attrName);
+                            removeValueAtPath(item, attrPath, exprAttrNames);
                         } else {
-                            item.set(attrName, newValue);
+                            setValueAtPath(item, attrPath, newValue, exprAttrNames);
                         }
                     }
                 }


Test patch:
diff --git a/compatibility-tests/sdk-test-awscli/test/dynamodb.bats b/compatibility-tests/sdk-test-awscli/test/dynamodb.bats
index 9d82ff2fb1..30f7250844 100644
--- a/compatibility-tests/sdk-test-awscli/test/dynamodb.bats
+++ b/compatibility-tests/sdk-test-awscli/test/dynamodb.bats
@@ -119,6 +119,91 @@ teardown() {
     [ "$name" = "Bob" ]
 }
 
+@test "DynamoDB: ADD resolves nested placeholder paths" {
+    aws_cmd dynamodb create-table \
+        --table-name "$TABLE_NAME" \
+        --attribute-definitions AttributeName=pk,AttributeType=S \
+        --key-schema AttributeName=pk,KeyType=HASH \
+        --billing-mode PAY_PER_REQUEST >/dev/null
+
+    ddb_wait_table "$TABLE_NAME"
+
+    aws_cmd dynamodb put-item \
+        --table-name "$TABLE_NAME" \
+        --item '{"pk":{"S":"item-1"},"functionInvocationSummaries":{"M":{"ISA_10136":{"M":{"eventsAvailable":{"N":"0"}}}}}}' >/dev/null
+
+    run aws_cmd dynamodb update-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}' \
+        --update-expression 'ADD #fis.#isa.#ea :one' \
+        --expression-attribute-names '{"#fis":"functionInvocationSummaries","#isa":"ISA_10136","#ea":"eventsAvailable"}' \
+        --expression-attribute-values '{":one":{"N":"1"}}'
+    assert_success
+
+    run aws_cmd dynamodb get-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}'
+    assert_success
+    nested_counter=$(json_get "$output" '.Item.functionInvocationSummaries.M.ISA_10136.M.eventsAvailable.N')
+    [ "$nested_counter" = "1" ]
+    phantom_attribute=$(echo "$output" | jq '.Item | has("#fis.#isa.#ea")')
+    [ "$phantom_attribute" = "false" ]
+}
+
+@test "DynamoDB: DELETE resolves nested placeholder paths" {
+    aws_cmd dynamodb create-table \
+        --table-name "$TABLE_NAME" \
+        --attribute-definitions AttributeName=pk,AttributeType=S \
+        --key-schema AttributeName=pk,KeyType=HASH \
+        --billing-mode PAY_PER_REQUEST >/dev/null
+
+    ddb_wait_table "$TABLE_NAME"
+
+    aws_cmd dynamodb put-item \
+        --table-name "$TABLE_NAME" \
+        --item '{"pk":{"S":"item-1"},"settings":{"M":{"labels":{"SS":["keep","drop"]}}}}' >/dev/null
+
+    run aws_cmd dynamodb update-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}' \
+        --update-expression 'DELETE #settings.#labels :labels' \
+        --expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
+        --expression-attribute-values '{":labels":{"SS":["drop"]}}'
+    assert_success
+
+    run aws_cmd dynamodb get-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}'
+    assert_success
+    labels=$(echo "$output" | jq -c '.Item.settings.M.labels.SS')
+    [ "$labels" = '["keep"]' ]
+    phantom_attribute=$(echo "$output" | jq '.Item | has("#settings.#labels")')
+    [ "$phantom_attribute" = "false" ]
+
+    run aws_cmd dynamodb update-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}' \
+        --update-expression 'DELETE #settings.#labels :labels' \
+        --expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
+        --expression-attribute-values '{":labels":{"SS":["keep"]}}'
+    assert_success
+
+    run aws_cmd dynamodb get-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}'
+    assert_success
+    nested_attribute=$(echo "$output" | jq '.Item.settings.M | has("labels")')
+    [ "$nested_attribute" = "false" ]
+
+    run aws_cmd dynamodb update-item \
+        --table-name "$TABLE_NAME" \
+        --key '{"pk":{"S":"item-1"}}' \
+        --update-expression 'DELETE #settings.#labels :labels' \
+        --expression-attribute-names '{"#settings":"settings","#labels":"labels"}' \
+        --expression-attribute-values '{":labels":{"SS":["missing"]}}'
+    assert_success
+}
+
 @test "DynamoDB: scan table" {
     aws_cmd dynamodb create-table \
         --table-name "$TABLE_NAME" \
diff --git a/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
index a1bd0c982e..a1d2aad110 100644
--- a/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/dynamodb/DynamoDbServiceTest.java
@@ -959,6 +959,10 @@ private ObjectNode stringSetAttributeValue(String... values) {
     private ObjectNode mapAttributeValue(String key, String value) {
         ObjectNode inner = mapper.createObjectNode();
         inner.set(key, attributeValue("S", value));
+        return mapAttributeValue(inner);
+    }
+
+    private ObjectNode mapAttributeValue(ObjectNode inner) {
         ObjectNode node = mapper.createObjectNode();
         node.set("M", inner);
         return node;
@@ -1362,6 +1366,176 @@ void testRemoveDeeplyNestedMapKey() {
         assertFalse(notifs.has("sms"), "sms should be removed");
     }
 
+    @Test
+    void updateItemAddResolvesNestedPlaceholderPath() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode invocationSummary = mapper.createObjectNode();
+        invocationSummary.set("eventsAvailable", attributeValue("N", "0"));
+        ObjectNode functionInvocationSummaries = mapper.createObjectNode();
+        functionInvocationSummaries.set("ISA_10136", mapAttributeValue(invocationSummary));
+
+        ObjectNode initialItem = item("userId", "nested-add-placeholder");
+        initialItem.set("functionInvocationSummaries", mapAttributeValue(functionInvocationSummaries));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode names = mapper.createObjectNode();
+        names.put("#fis", "functionInvocationSummaries");
+        names.put("#isa", "ISA_10136");
+        names.put("#ea", "eventsAvailable");
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":one", attributeValue("N", "1"));
+
+        service.updateItem("Users", userIdKey("nested-add-placeholder"), null,
+                "ADD #fis.#isa.#ea :one", names, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("nested-add-placeholder"), region);
+        assertEquals(1, stored.path("functionInvocationSummaries").path("M")
+                .path("ISA_10136").path("M").path("eventsAvailable").path("N").asInt());
+        assertFalse(stored.has("#fis.#isa.#ea"), "ADD must not create a phantom top-level attribute");
+    }
+
+    @Test
+    void updateItemAddTraversesLiteralDottedPath() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode nested = mapper.createObjectNode();
+        nested.set("c", attributeValue("N", "4"));
+        ObjectNode parent = mapper.createObjectNode();
+        parent.set("b", mapAttributeValue(nested));
+        ObjectNode initialItem = item("userId", "nested-add-literal");
+        initialItem.set("a", mapAttributeValue(parent));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":three", attributeValue("N", "3"));
+
+        service.updateItem("Users", userIdKey("nested-add-literal"), null,
+                "ADD a.b.c :three", null, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("nested-add-literal"), region);
+        assertEquals("7", stored.path("a").path("M").path("b").path("M").path("c").path("N").asText());
+        assertFalse(stored.has("a.b.c"), "ADD must not create a phantom top-level attribute");
+    }
+
+    @Test
+    void updateItemAddSeedsMissingNestedLeafUnderExistingParents() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode parent = mapper.createObjectNode();
+        parent.set("b", mapAttributeValue(mapper.createObjectNode()));
+        ObjectNode initialItem = item("userId", "nested-add-missing-leaf");
+        initialItem.set("a", mapAttributeValue(parent));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":five", attributeValue("N", "5"));
+
+        service.updateItem("Users", userIdKey("nested-add-missing-leaf"), null,
+                "ADD a.b.c :five", null, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("nested-add-missing-leaf"), region);
+        assertEquals("5", stored.path("a").path("M").path("b").path("M").path("c").path("N").asText());
+        assertFalse(stored.has("a.b.c"), "ADD must write the missing leaf at its document path");
+    }
+
+    @Test
+    void updateItemAddUnionsNestedSetAtPlaceholderPath() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode metadata = mapper.createObjectNode();
+        metadata.set("tags", stringSetAttributeValue("alpha", "beta"));
+        ObjectNode initialItem = item("userId", "nested-add-set");
+        initialItem.set("metadata", mapAttributeValue(metadata));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode names = mapper.createObjectNode();
+        names.put("#meta", "metadata");
+        names.put("#tags", "tags");
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":tags", stringSetAttributeValue("beta", "gamma"));
+
+        service.updateItem("Users", userIdKey("nested-add-set"), null,
+                "ADD #meta.#tags :tags", names, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("nested-add-set"), region);
+        java.util.Set<String> tags = new java.util.HashSet<>();
+        stored.path("metadata").path("M").path("tags").path("SS").forEach(value -> tags.add(value.asText()));
+        assertEquals(java.util.Set.of("alpha", "beta", "gamma"), tags);
+        assertFalse(stored.has("#meta.#tags"), "ADD must not create a phantom top-level attribute");
+    }
+
+    @Test
+    void updateItemDeleteMutatesAndRemovesNestedSetAtPlaceholderPath() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode settings = mapper.createObjectNode();
+        settings.set("labels", stringSetAttributeValue("keep", "drop"));
+        ObjectNode initialItem = item("userId", "nested-delete-set");
+        initialItem.set("settings", mapAttributeValue(settings));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode names = mapper.createObjectNode();
+        names.put("#settings", "settings");
+        names.put("#labels", "labels");
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":labels", stringSetAttributeValue("drop"));
+
+        service.updateItem("Users", userIdKey("nested-delete-set"), null,
+                "DELETE #settings.#labels :labels", names, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("nested-delete-set"), region);
+        JsonNode remaining = stored.path("settings").path("M").path("labels").path("SS");
+        assertEquals(1, remaining.size());
+        assertEquals("keep", remaining.get(0).asText());
+
+        values.set(":labels", stringSetAttributeValue("keep"));
+        service.updateItem("Users", userIdKey("nested-delete-set"), null,
+                "DELETE #settings.#labels :labels", names, values, "ALL_NEW", region);
+
+        stored = service.getItem("Users", userIdKey("nested-delete-set"), region);
+        assertFalse(stored.path("settings").path("M").has("labels"),
+                "DELETE must remove a nested attribute when its set becomes empty");
+        assertFalse(stored.has("#settings.#labels"), "DELETE must not create a phantom top-level attribute");
+
+        assertDoesNotThrow(() -> service.updateItem("Users", userIdKey("nested-delete-set"), null,
+                "DELETE #settings.#labels :labels", names, values, "ALL_NEW", region));
+        JsonNode afterMissingPathDelete = service.getItem("Users", userIdKey("nested-delete-set"), region);
+        assertFalse(afterMissingPathDelete.path("settings").path("M").has("labels"),
+                "DELETE on a missing nested path must be a no-op");
+    }
+
+    @Test
+    void updateItemTopLevelAddAndDeleteRemainUnchanged() {
+        String region = "eu-west-1";
+        createUsersTable(region);
+
+        ObjectNode initialItem = item("userId", "top-level-add-delete");
+        initialItem.set("counter", attributeValue("N", "2"));
+        initialItem.set("tags", stringSetAttributeValue("keep", "drop"));
+        service.putItem("Users", initialItem, region);
+
+        ObjectNode names = mapper.createObjectNode();
+        names.put("#counter", "counter");
+        names.put("#tags", "tags");
+        ObjectNode values = mapper.createObjectNode();
+        values.set(":three", attributeValue("N", "3"));
+        values.set(":drop", stringSetAttributeValue("drop"));
+
+        service.updateItem("Users", userIdKey("top-level-add-delete"), null,
+                "ADD #counter :three DELETE #tags :drop", names, values, "ALL_NEW", region);
+
+        JsonNode stored = service.getItem("Users", userIdKey("top-level-add-delete"), region);
+        assertEquals("5", stored.path("counter").path("N").asText());
+        assertEquals(1, stored.path("tags").path("SS").size());
+        assertEquals("keep", stored.path("tags").path("SS").get(0).asText());
+    }
+
     // --- UpdateExpression clause separator tests ---
     //
     // The Go AWS SDK v2 expression.Builder joins top-level clauses with '\n',