diff --git a/crates/core/src/expression/mod.rs b/crates/core/src/expression/mod.rs index db2613a7e..e6d61d4cd 100755 --- a/crates/core/src/expression/mod.rs +++ b/crates/core/src/expression/mod.rs @@ -20,6 +20,7 @@ mod resolver; mod search_condition; mod tokenizer; mod update_evaluator; +mod update_nesting; mod update_parser; pub use ast::{ArithOp, CompareOp, Expr, PathElement, UpdateAction}; diff --git a/crates/core/src/expression/update_evaluator.rs b/crates/core/src/expression/update_evaluator.rs index 52f2a149b..ec278d8c4 100755 --- a/crates/core/src/expression/update_evaluator.rs +++ b/crates/core/src/expression/update_evaluator.rs @@ -113,6 +113,7 @@ pub fn apply_update_validated( } } apply_update(actions, item, maps)?; + super::update_nesting::validate_written_nesting_depth(actions, item, maps)?; crate::validation::validate_vector_write_changed( item, &before, diff --git a/crates/core/src/expression/update_nesting.rs b/crates/core/src/expression/update_nesting.rs new file mode 100644 index 000000000..48e848379 --- /dev/null +++ b/crates/core/src/expression/update_nesting.rs @@ -0,0 +1,156 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Nesting depth of the attributes an update expression writes. + +use std::collections::BTreeMap; + +use crate::error::DynamoDbError; +use crate::types::AttributeValue; + +use super::ast::UpdateAction; +use super::resolver::{ExpressionMaps, resolve_element_name}; + +/// Most M or L wrappers an attribute value may have: 31 wrappers around a +/// scalar is 32 levels, the service's limit. +const MAX_WRAPPERS: usize = 31; + +/// Refuse an updated item whose SET or ADD targets now nest beyond the limit. +/// +/// Only the top-level attributes a SET or an ADD writes are checked, each +/// once however many actions write into it. ADD is checked because it +/// creates the missing maps on its path. REMOVE and DELETE cannot deepen a +/// value, and an attribute the update does not touch is left alone, so an +/// item stored deeper than the limit before this check existed stays +/// updatable through its other attributes. +/// +/// # Errors +/// +/// `ValidationException` `Nesting Levels have exceeded supported limits`, the +/// service's text for UpdateItem and for a TransactWriteItems Update +/// cancellation reason (measured 2026-10-25). +pub(super) fn validate_written_nesting_depth( + actions: &[UpdateAction], + item: &BTreeMap, + maps: &ExpressionMaps, +) -> Result<(), DynamoDbError> { + let mut written = Vec::new(); + for action in actions { + let (UpdateAction::Set { path, .. } | UpdateAction::Add { path, .. }) = action else { + continue; + }; + let Some(first) = path.first() else { + continue; + }; + let name = resolve_element_name(first, maps)?; + if !written.contains(&name) { + written.push(name); + } + } + for name in &written { + if item + .get(name.as_ref()) + .is_some_and(|v| wrappers_exceed(v, 0)) + { + return Err(DynamoDbError::ValidationException( + "Nesting Levels have exceeded supported limits".to_owned(), + )); + } + } + Ok(()) +} + +/// Whether `value`, sitting under `above` wrappers, has more than +/// [`MAX_WRAPPERS`] wrappers on any path down to a scalar. +fn wrappers_exceed(value: &AttributeValue, above: usize) -> bool { + let here = above + 1; + match value { + AttributeValue::M(map) => { + here > MAX_WRAPPERS || map.values().any(|v| wrappers_exceed(v, here)) + } + AttributeValue::L(list) => { + here > MAX_WRAPPERS || list.iter().any(|v| wrappers_exceed(v, here)) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use super::*; + use crate::expression::{parse_update, tokenize}; + + fn nmap(wrappers: usize) -> AttributeValue { + let mut v = AttributeValue::S("leaf".to_owned()); + for _ in 0..wrappers { + v = AttributeValue::M(BTreeMap::from([("k".to_owned(), v)])); + } + v + } + + fn check(expr: &str, item: &BTreeMap) -> Result<(), DynamoDbError> { + let actions = parse_update(&tokenize(expr).unwrap()).unwrap(); + // The names map is keyed without the leading `#`. + let maps = ExpressionMaps::new( + HashMap::from([("m".to_owned(), "m".to_owned())]), + HashMap::new(), + ); + validate_written_nesting_depth(&actions, item, &maps) + } + + fn is_nesting_error(result: Result<(), DynamoDbError>) -> bool { + matches!( + result, + Err(DynamoDbError::ValidationException(m)) + if m == "Nesting Levels have exceeded supported limits" + ) + } + + #[test] + fn thirty_one_wrappers_pass_and_thirty_two_are_refused() { + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("SET #m = :v", &at).is_ok()); + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("SET #m.k = :v", &over))); + } + + #[test] + fn a_list_counts_as_a_wrapper_and_every_branch_is_checked() { + let mut deep = AttributeValue::S("leaf".to_owned()); + for _ in 0..32 { + deep = AttributeValue::L(vec![AttributeValue::N("1".to_owned()), deep]); + } + let item = BTreeMap::from([("m".to_owned(), deep)]); + assert!(is_nesting_error(check("SET #m[0] = :v", &item))); + } + + #[test] + fn attributes_the_update_does_not_set_are_not_checked() { + let item = BTreeMap::from([ + ("m".to_owned(), nmap(40)), + ("x".to_owned(), AttributeValue::S("y".to_owned())), + ]); + assert!(check("SET x = :v", &item).is_ok()); + assert!(check("REMOVE #m.k", &item).is_ok()); + assert!(is_nesting_error(check("SET #m.k = :v", &item))); + assert!(check("DELETE #m.k :v", &item).is_ok()); + } + + #[test] + fn an_add_target_is_checked() { + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("ADD #m.k :v", &over))); + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("ADD #m.k :v", &at).is_ok()); + } + + #[test] + fn two_actions_into_one_attribute_are_checked_as_one() { + let over = BTreeMap::from([("m".to_owned(), nmap(32))]); + assert!(is_nesting_error(check("SET #m.k = :v, #m.j = :v", &over))); + let at = BTreeMap::from([("m".to_owned(), nmap(31))]); + assert!(check("SET #m.k = :v, #m.j = :v ADD #m.i :v", &at).is_ok()); + } +} diff --git a/tests/test_update_result_nesting_depth.py b/tests/test_update_result_nesting_depth.py new file mode 100644 index 000000000..b2cce5978 --- /dev/null +++ b/tests/test_update_result_nesting_depth.py @@ -0,0 +1,194 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Nesting depth of the item an UpdateItem or a TransactWriteItems Update produces. + +Amazon DynamoDB refuses an update whose result nests an attribute value more +than 32 levels (31 M or L wrappers around a scalar) and writes nothing: +UpdateItem answers ValidationException ``Nesting Levels have exceeded +supported limits``, and a TransactWriteItems Update is cancelled with a +``ValidationError`` reason carrying the same text. A failing condition is +reported first. Measured on 2026-10-25 at 19:53 UTC and 2026-10-26 at 00:55 +UTC (us-east-1), each case twice. + +Depth below is counted in wrappers: a value with w wrappers has w + 1 levels. + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +MESSAGE = "Nesting Levels have exceeded supported limits" +NAMES = {"#m": "m", "#k": "k"} + + +def nmap(wrappers: int, leaf: dict | None = None) -> dict: + value = leaf or {"S": "leaf"} + for _ in range(wrappers): + value = {"M": {"k": value}} + return value + + +def nlist(wrappers: int) -> dict: + value = {"S": "leaf"} + for _ in range(wrappers): + value = {"L": [value]} + return value + + +def wrappers(value: dict) -> int: + """Most wrappers on any branch; an empty map or list adds none.""" + children = list(value.get("M", {}).values()) or value.get("L", []) + return 1 + max(wrappers(c) for c in children) if children else 0 + + +def path(j: int) -> str: + return "#m" + ".#k" * j + + +@pytest.fixture(scope="module") +def table(dynamodb_client): + with scoped_table( + dynamodb_client, + attribute_definitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + key_schema=[ + {"AttributeName": "pk", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + ) as name: + yield name + + +def _put(client, table, sk, m): + key = {"pk": {"S": "nest"}, "sk": {"S": sk}} + item = {**key, "x": {"S": "x"}} + if m is not None: + item["m"] = m + client.put_item(TableName=table, Item=item) + return key + + +def _stored_wrappers(client, table, key): + item = client.get_item(TableName=table, Key=key, ConsistentRead=True)["Item"] + return wrappers(item["m"]) if "m" in item else None + + +# (case, base value of m, update expression, names, values, wrappers stored after) +ACCEPTED = [ + ("j29_w2", nmap(30), f"SET {path(29)} = :v", NAMES, {":v": nmap(2)}, 31), + ("j30_w1", nmap(30), f"SET {path(30)} = :v", NAMES, {":v": nmap(1)}, 31), + ("j1_w30", nmap(30), f"SET {path(1)} = :v", NAMES, {":v": nmap(30)}, 31), + ("j15_w16", nmap(30), f"SET {path(15)} = :v", NAMES, {":v": nmap(16)}, 31), + ("top_w31", None, "SET #m = :v", {"#m": "m"}, {":v": nmap(31)}, 31), + ("new_j1_w30", {"M": {}}, f"SET {path(1)} = :v", NAMES, {":v": nmap(30)}, 31), + ("list_j30_w1", nlist(30), "SET #m" + "[0]" * 30 + " = :v", {"#m": "m"}, {":v": nlist(1)}, 31), + ("if_not_exists_kept", nmap(30), f"SET {path(30)} = if_not_exists({path(30)}, :v)", NAMES, + {":v": nmap(2)}, 30), + ("at_limit_scalar", nmap(31), f"SET {path(31)} = :v", NAMES, {":v": {"S": "y"}}, 31), +] + +REFUSED = [ + ("j29_w3", nmap(30), f"SET {path(29)} = :v", NAMES, {":v": nmap(3)}, 30), + ("j30_w2", nmap(30), f"SET {path(30)} = :v", NAMES, {":v": nmap(2)}, 30), + ("j1_w31", nmap(30), f"SET {path(1)} = :v", NAMES, {":v": nmap(31)}, 30), + ("j15_w17", nmap(30), f"SET {path(15)} = :v", NAMES, {":v": nmap(17)}, 30), + ("new_j1_w31", {"M": {}}, f"SET {path(1)} = :v", NAMES, {":v": nmap(31)}, 0), + ("list_j30_w2", nlist(30), "SET #m" + "[0]" * 30 + " = :v", {"#m": "m"}, {":v": nlist(2)}, 30), + ("list_append", nmap(29, {"L": [{"S": "a"}]}), f"SET {path(29)} = list_append({path(29)}, :v)", NAMES, + {":v": {"L": [nmap(2)]}}, 30), + ("if_not_exists_default", nmap(29, {"M": {}}), f"SET {path(30)} = if_not_exists(#m.#z, :v)", + {**NAMES, "#z": "z"}, {":v": nmap(2)}, 29), +] + + +@pytest.mark.parametrize(("case", "base", "expr", "names", "values", "after"), ACCEPTED, + ids=[c[0] for c in ACCEPTED]) +def test_update_up_to_the_limit_is_applied(dynamodb_client, table, case, base, expr, names, values, after): + key = _put(dynamodb_client, table, f"ok-{case}", base) + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=expr, + ExpressionAttributeNames=names, ExpressionAttributeValues=values, + ) + assert _stored_wrappers(dynamodb_client, table, key) == after + + +@pytest.mark.parametrize(("case", "base", "expr", "names", "values", "after"), REFUSED, + ids=[c[0] for c in REFUSED]) +def test_update_past_the_limit_is_refused(dynamodb_client, table, case, base, expr, names, values, after): + key = _put(dynamodb_client, table, f"no-{case}", base) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=expr, + ExpressionAttributeNames=names, ExpressionAttributeValues=values, + ) + err = exc_info.value.response["Error"] + assert (err["Code"], err["Message"]) == ("ValidationException", MESSAGE) + assert _stored_wrappers(dynamodb_client, table, key) == after + + +def test_an_add_that_would_create_maps_past_the_limit_is_refused(dynamodb_client, table): + # The service refuses this path for its 33 elements (measured 2026-10-26 + # at 01:26 UTC) and ExtendDB for the depth of the maps ADD would create, + # so only the code and the stored item are compared. + key = _put(dynamodb_client, table, "add-deep", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=f"ADD {path(29)}.#a.#b.#c :one", + ExpressionAttributeNames={**NAMES, "#a": "a", "#b": "b", "#c": "c"}, + ExpressionAttributeValues={":one": {"N": "1"}}, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_a_failing_condition_is_reported_before_the_depth(dynamodb_client, table): + key = _put(dynamodb_client, table, "condfail", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression=f"SET {path(30)} = :v", + ConditionExpression="#x = :no", + ExpressionAttributeNames={**NAMES, "#x": "x"}, + ExpressionAttributeValues={":v": nmap(2), ":no": {"S": "no"}}, + ) + assert exc_info.value.response["Error"]["Code"] == "ConditionalCheckFailedException" + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_an_item_at_the_limit_takes_an_update_of_another_attribute(dynamodb_client, table): + key = _put(dynamodb_client, table, "other-attr", nmap(31)) + dynamodb_client.update_item( + TableName=table, Key=key, UpdateExpression="SET #x = :v", + ExpressionAttributeNames={"#x": "x"}, ExpressionAttributeValues={":v": {"S": "y"}}, + ) + assert _stored_wrappers(dynamodb_client, table, key) == 31 + + +def test_transact_update_past_the_limit_is_cancelled(dynamodb_client, table): + key = _put(dynamodb_client, table, "tx-no", nmap(30)) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, "UpdateExpression": f"SET {path(30)} = :v", + "ExpressionAttributeNames": NAMES, "ExpressionAttributeValues": {":v": nmap(2)}, + }}]) + resp = exc_info.value.response + assert resp["Error"]["Code"] == "TransactionCanceledException" + reasons = [(r.get("Code"), r.get("Message")) for r in resp["CancellationReasons"]] + assert reasons == [("ValidationError", MESSAGE)] + assert _stored_wrappers(dynamodb_client, table, key) == 30 + + +def test_transact_update_up_to_the_limit_is_applied(dynamodb_client, table): + key = _put(dynamodb_client, table, "tx-ok", nmap(30)) + dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, "UpdateExpression": f"SET {path(29)} = :v", + "ExpressionAttributeNames": NAMES, "ExpressionAttributeValues": {":v": nmap(2)}, + }}]) + assert _stored_wrappers(dynamodb_client, table, key) == 31