Skip to content

UpdateExpression ADD creates no missing map on its path - #596

Open
LeeroyHannigan wants to merge 3 commits into
mainfrom
fix/update-add-missing-parent
Open

LeeroyHannigan wants to merge 3 commits into
mainfrom
fix/update-add-missing-parent

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

An UpdateExpression ADD on a nested path now needs every element above the leaf to exist, as DynamoDB requires. Before, ExtendDB created each missing map on the way, so ADD #z.#a :one on an item without z stored z: {a: 1}, and on a missing key it created the item with that map.

What the service answers

Measured on 2026-10-26 at 02:38, 02:54, 03:33 and 03:41 UTC in us-east-1, each case twice with the same answer.

  • ADD #m.#a :one with m an empty map is applied (m: {a: 1}), with a number or a set. So is ADD #l[0].#a :one with l a list holding an empty map.
  • Every element above the leaf must exist and be the container the next element needs: a map for a name, a list for an index. Otherwise the update answers ValidationException The document path provided in the update expression is invalid for update and writes nothing, the request's other actions included. That covers a missing top-level attribute, a missing map below an existing one, a string, number or list where a map is needed, a map or a string where a list is needed, and an index past the end of a list.
  • On a missing key, ADD #z.#a :one answers the same and creates no item.
  • A failing condition is reported first (ConditionalCheckFailedException); with a passing condition the answer is the refusal above.
  • In TransactWriteItems the Update is cancelled with one ValidationError reason carrying the same text.

The change

crates/core/src/expression/update_evaluator.rs: apply_add reaches the leaf's parent through the new navigate_to_existing_parent_map, which refuses a missing or wrong-type element with the service's text, in place of navigate_to_parent_map_or_create. An index past the end of a list above the leaf was answered with The provided expression refers to an attribute that does not exist in the item; it now has the service's text too. The file goes from 642 to 628 lines, and the new unit tests are in a file of their own. Every backend applies nested ADD through this function: the MongoDB native update path takes only one-element paths.

What is not changed here

  • REMOVE and DELETE through a missing or wrong-type parent. The service refuses them on an existing item with the same text and, when the key holds no item, answers OK and stores nothing (measured at 02:54 UTC). ExtendDB treats the action as a no-op in both cases; on a missing key it then stores an item with only the key, which fix(update): write nothing for a removal-only UpdateItem on a missing key #508 addresses. Telling the two cases apart needs the evaluator to know whether the item existed, which is a separate change.
  • ADD into a list element (ADD #l[0] :one). The service adds to the element, or appends when the index is past the end; ExtendDB refuses it, before and after this change, now with the service's refusal text for a map parent.
  • The service resolves an ADD path against the item as it was before the update. ExtendDB applies SET and REMOVE first, so SET #l[5] = :m ADD #l[1].#a :one on l: [1] is refused by the service and applied by ExtendDB, and REMOVE #l[0] ADD #l[1].#a :one is applied by the service to the element that was at index 1 and by ExtendDB to the next one or refused.
  • SET #z = :m ADD #z.#a :one and REMOVE #m ADD #m.#a :one answer the service's overlapping paths error; ExtendDB has no overlap check on UpdateExpression (fix(expressions): validate in the service's order, with its messages #399 adds one).
  • ADD of a string or a list answers the service's operand type error before the path is looked at; ExtendDB answers its own operand text where the parent exists and the path text where it does not (fix(expressions): validate in the service's order, with its messages #399 adds the service's operand check).

Tests

  • crates/core/src/expression/update_add_parent_tests.rs (new): a member of an existing map, a missing parent, a parent of the wrong type, an index past the end of a list of maps, a map inside a list.
  • tests/test_update_add_missing_parent.py: 23 dual-target wire tests from the measurements.

Run locally against SQLite on 2026-10-26:

  • the new file at the head: 23 passed; on main: 15 failed, 8 passed. The 8 that pass on main are the applied cases, the failing condition, and the four refusals where main already refused a parent that is not a map (a string, a number, a list, or an index into a string).
  • at the head, every file under tests/ whose test names match update, add, transact, nested, item_operations, conditional or set: 461 passed, 7 skipped, with the new file at 21 tests; on main the same 448 pass and the new file's 13 fail.
  • at the head, tests/python files matching update, add, transact, nested or set: 73 passed.
  • cargo +1.97.0 fmt --all -- --check, cargo +1.97.0 clippy --all-targets -- -D warnings, cargo test --workspace --exclude extenddb-storage-cassandra on stable and on 1.88.0 (1,772 passed each), and cargo test -p extenddb-storage-cassandra --lib (61 passed). The 21-test version of the new file passed on PostgreSQL 16, MongoDB 7 and Cassandra 4.1 as well; on PostgreSQL and MongoDB the related files fail the same tests at the head as on main, apart from the new file's.

CI runs the new file in run-integration, run-integration-sqlite, mongodb-pytest and cassandra-pytest.

Merging

git merge-tree --write-tree is clean against #590, the one open PR that changes update_evaluator.rs, and the merged tree passes both PRs' wire files. #590 checks the nesting depth of ADD targets because ADD created missing maps; after this change its test ADD path(29).#a.#b.#c :one is refused for the missing a before the depth check, and #590's comment on ADD needs updating in whichever PR merges second. Its depth check on ADD still covers an item stored deeper than the limit before the check existed.

Amazon DynamoDB applies `ADD #m.#a :n` only when every element above the
leaf exists and is the container the next element needs; otherwise it
answers `The document path provided in the update expression is invalid
for update` and writes nothing (measured 2026-10-26 at 02:38 and 02:54
UTC, each case twice). ExtendDB created each missing map, so
`ADD #z.#a :one` on an item without `z` stored `z: {a: 1}`, and on a
missing key created the item.

`apply_add` now reaches the leaf's parent through
`navigate_to_existing_parent_map`, which refuses a missing or wrong-type
element, and an index past the end of a list, with the service's text.
REMOVE and DELETE through a missing parent are unchanged: the service
accepts them when the key holds no item and refuses them otherwise, and
the evaluator does not know which case it is in.
Dual-target wire tests from the 2026-10-26 measurements: a missing
top-level attribute, a missing map below an existing one, a parent of the
wrong type for a name or an index, an index past the end of a list, a
missing key, the request's other actions left unapplied, a failing and a
passing condition, and the TransactWriteItems cancellation reason; and the
applied cases, a member of an existing map and of a map inside a list.
An index past the end of a list was refused in the tests only where the
list held a number, which a parent of the wrong type refuses too, so an
index clamped to the last element passed them. The new rows put a map in
the list: `ADD #m.#l[3].#a :one` with `m: {l: [{}]}` and `ADD #l[0].#a
:one` with `l: []`, both refused by the service (measured 2026-10-26 at
03:33 UTC, twice), and the matching unit test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants