From 75a783b3544b2f8496cbe340b72b13b4de7829c2 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:43:45 +0000 Subject: [PATCH 1/4] fix(expression): refuse a document path of more than 32 elements Amazon DynamoDB refuses a document path of more than 32 elements, each attribute name and each list index counting as one, in every expression kind, with `Invalid : The document path has too many nesting levels; nesting levels: ` (measured 2026-10-26 at 02:26, 02:29, 03:38 and 03:39 UTC, each request twice). It checks each path's `#name` references and then its length, path by path in text order. ExtendDB had no limit: a 33-element path was evaluated, and an ADD on one created the missing maps. The check runs where each path's names are resolved: in the semantic walk's `check_path_refs` for update and condition expressions, in `resolve_paths` for projections, and in the key condition parser before the nested-attribute refusal. The grammar pass still runs first, so a syntax error is reported before the length. Two checks move to the service's position so the new refusal does not answer ahead of them (measured 2026-10-26 at 04:40, 04:41 and 04:43 UTC): - The BETWEEN bounds check (both bounds placeholders of different types, or the lower above the upper) runs in the semantic walk, so it answers at request time on a missing key, after a false AND and on an empty Query, as the service does; it ran only while evaluating an item. - UpdateItem and PutItem check the nesting depth of ExpressionAttributeValues right after the table name, before the expressions, the table lookup and the key, as DeleteItem already did. --- crates/core/src/expression/evaluator.rs | 63 +++++---- crates/core/src/expression/key_condition.rs | 7 + crates/core/src/expression/mod.rs | 2 + crates/core/src/expression/path_length.rs | 129 ++++++++++++++++++ crates/core/src/expression/projection.rs | 4 + .../expression/semantics/condition_rules.rs | 8 +- crates/core/src/expression/semantics/refs.rs | 10 +- crates/engine/src/put_item.rs | 24 ++-- crates/engine/src/update_item.rs | 27 ++-- docs/dynamodb-limits.md | 1 + 10 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 crates/core/src/expression/path_length.rs diff --git a/crates/core/src/expression/evaluator.rs b/crates/core/src/expression/evaluator.rs index cee1186ea..2aa2cff79 100755 --- a/crates/core/src/expression/evaluator.rs +++ b/crates/core/src/expression/evaluator.rs @@ -53,29 +53,10 @@ pub fn evaluate_condition( let val = resolve_to_value(operand, item, maps)?; let lo = resolve_to_value(low, item, maps)?; let hi = resolve_to_value(high, item, maps)?; - // DynamoDB validates bounds when both are literal placeholders - if matches!(low.as_ref(), Expr::Placeholder(_)) - && matches!(high.as_ref(), Expr::Placeholder(_)) - && let (Some(l), Some(h)) = (lo.as_deref(), hi.as_deref()) - { - let l_type = attribute_type_code(l); - let h_type = attribute_type_code(h); - if l_type != h_type { - return Err(DynamoDbError::ValidationException(format!( - "Invalid ConditionExpression: The BETWEEN operator requires same data type for lower and upper bounds; lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}", - format_attribute_value(l), - format_attribute_value(h) - ))); - } - let lpn = placeholder_numeric(low, maps); - let hpn = placeholder_numeric(high, maps); - if compare_values(l, h, CompareOp::Gt, lpn, hpn) { - return Err(DynamoDbError::ValidationException(format!( - "Invalid ConditionExpression: The BETWEEN operator requires upper bound to be greater than or equal to lower bound; lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}", - format_attribute_value(l), - format_attribute_value(h) - ))); - } + if let Some(msg) = between_bounds_fault(low, high, maps) { + return Err(DynamoDbError::ValidationException(format!( + "Invalid ConditionExpression: {msg}" + ))); } match (&val, &lo, &hi) { (Some(v), Some(l), Some(h)) => { @@ -127,6 +108,42 @@ fn resolve_to_value<'a>( } } +/// The service's sentence when both BETWEEN bounds are placeholders and they +/// differ in type or the lower is above the upper, without the `Invalid +/// : ` label. The service checks this at request time, whatever the +/// item (measured 2026-10-26). +pub(crate) fn between_bounds_fault( + low: &Expr, + high: &Expr, + maps: &ExpressionMaps, +) -> Option { + let (Expr::Placeholder(l_name), Expr::Placeholder(h_name)) = (low, high) else { + return None; + }; + let (Some(l), Some(h)) = (maps.values.get(l_name), maps.values.get(h_name)) else { + return None; + }; + let bounds = format!( + "lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}", + format_attribute_value(l), + format_attribute_value(h) + ); + if attribute_type_code(l) != attribute_type_code(h) { + return Some(format!( + "The BETWEEN operator requires same data type for lower and upper bounds; {bounds}" + )); + } + let (lpn, hpn) = ( + placeholder_numeric(low, maps), + placeholder_numeric(high, maps), + ); + compare_values(l, h, CompareOp::Gt, lpn, hpn).then(|| { + format!( + "The BETWEEN operator requires upper bound to be greater than or equal to lower bound; {bounds}" + ) + }) +} + /// Look up a pre-parsed `BigDecimal` for a placeholder expression. /// /// Returns `None` if the expression is not a placeholder or has no pre-parsed value. diff --git a/crates/core/src/expression/key_condition.rs b/crates/core/src/expression/key_condition.rs index d8fb7122d..54c2dd8a7 100755 --- a/crates/core/src/expression/key_condition.rs +++ b/crates/core/src/expression/key_condition.rs @@ -628,6 +628,13 @@ fn parse_clauses(tokens: &[Token]) -> Result, DynamoDbError> { // A key condition may only reference a top-level key attribute. Any document // path (`attr.sub` or `attr[i]`) on a key is rejected, matching Amazon // DynamoDB. Runs after the reserved-word check and before key-schema checks. + for clause in &clauses { + if let Some(msg) = super::path_length::too_many_levels(raw_clause_path(clause)) { + return Err(validation_err(&format!( + "Invalid KeyConditionExpression: {msg}" + ))); + } + } for clause in &clauses { reject_nested_key_path(raw_clause_path(clause))?; } diff --git a/crates/core/src/expression/mod.rs b/crates/core/src/expression/mod.rs index 86eef4d1b..6aa980ebf 100755 --- a/crates/core/src/expression/mod.rs +++ b/crates/core/src/expression/mod.rs @@ -14,6 +14,7 @@ mod key_condition; mod kind; mod parser; mod parser_common; +mod path_length; mod projection; mod reserved_words; mod resolver; @@ -31,6 +32,7 @@ pub use key_condition::{ }; pub use kind::ExpressionKind; pub use parser::{parse_condition, parse_condition_with_depth_limit}; +pub use path_length::MAX_DOCUMENT_PATH_ELEMENTS; pub use projection::{Projection, parse_projection}; pub use reserved_words::validate_no_reserved_words; pub use resolver::{ diff --git a/crates/core/src/expression/path_length.rs b/crates/core/src/expression/path_length.rs new file mode 100644 index 000000000..bfa33aff1 --- /dev/null +++ b/crates/core/src/expression/path_length.rs @@ -0,0 +1,129 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Length limit of a document path in an expression. +//! +//! Amazon `DynamoDB` refuses a document path of more than 32 elements, each +//! attribute name and each list index counting as one, in every expression +//! kind: `Invalid : The document path has too many nesting levels; +//! nesting levels: `. It checks a path's `#name` references first and then +//! its length, path by path in the order the paths appear, so an undefined +//! `#name` in an earlier path or inside the long path is reported instead +//! (measured 2026-10-26, us-east-1). The callers run the check where they +//! resolve each path's names. + +use super::ast::PathElement; + +/// Most elements a document path may have. +pub const MAX_DOCUMENT_PATH_ELEMENTS: usize = 32; + +/// The service's sentence for `path` when it is too long, without the +/// `Invalid : ` label the caller adds. +pub(crate) fn too_many_levels(path: &[PathElement]) -> Option { + (path.len() > MAX_DOCUMENT_PATH_ELEMENTS).then(|| { + format!( + "The document path has too many nesting levels; nesting levels: {}", + path.len() + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(n: usize) -> Vec { + (0..n) + .map(|_| PathElement::Attribute("k".to_owned())) + .collect() + } + + #[test] + fn thirty_two_elements_pass_and_thirty_three_are_refused() { + assert_eq!(too_many_levels(&names(32)), None); + assert_eq!( + too_many_levels(&names(33)).as_deref(), + Some("The document path has too many nesting levels; nesting levels: 33") + ); + } + + #[test] + fn a_list_index_counts_as_an_element() { + let mut path = names(1); + path.extend((0..32).map(PathElement::Index)); + assert_eq!(path.len(), 33); + assert!(too_many_levels(&path).is_some()); + path.pop(); + assert_eq!(too_many_levels(&path), None); + } + + fn path_text(first: &str, n: usize) -> String { + format!("{first}{}", ".k".repeat(n - 1)) + } + + fn message(result: Result<(), crate::error::DynamoDbError>) -> String { + match result { + Err(crate::error::DynamoDbError::ValidationException(m)) => m, + other => panic!("{other:?}"), + } + } + + fn maps() -> crate::expression::ExpressionMaps { + crate::expression::ExpressionMaps::new( + std::collections::HashMap::from([("m".to_owned(), "m".to_owned())]), + std::collections::HashMap::from([( + "v".to_owned(), + crate::types::AttributeValue::S("v".to_owned()), + )]), + ) + } + + const LEVELS_33: &str = "The document path has too many nesting levels; nesting levels: 33"; + + #[test] + fn a_key_condition_path_is_checked_before_the_nested_refusal() { + let parse = |n: usize| { + let expr = format!("pk = :v AND {} = :v", path_text("m", n)); + crate::expression::parse_key_condition(&crate::expression::tokenize(&expr).unwrap()) + .map(|_| ()) + }; + assert_eq!( + message(parse(33)), + format!("Invalid KeyConditionExpression: {LEVELS_33}") + ); + assert!(message(parse(32)).contains("cannot have conditions on nested attributes")); + } + + #[test] + fn a_projection_path_is_checked_after_its_names() { + let compile = |text: &str| { + let paths = + crate::expression::parse_projection(&crate::expression::tokenize(text).unwrap()) + .unwrap(); + crate::expression::Projection::compile(&paths, &maps(), true).map(|_| ()) + }; + assert_eq!( + message(compile(&path_text("#m", 33))), + format!("Invalid ProjectionExpression: {LEVELS_33}") + ); + assert!(message(compile(&path_text("#zz", 33))).contains("attribute name: #zz")); + assert!(compile(&path_text("#m", 32)).is_ok()); + } + + #[test] + fn an_update_path_is_checked_after_its_names_in_text_order() { + let check = |text: &str| { + let tokens = crate::expression::tokenize(text).unwrap(); + let parsed = crate::expression::parse_update_sections(&tokens, Some(text)).unwrap(); + crate::expression::validate_update_semantics(&parsed, &maps()) + }; + let long = path_text("#m", 33); + assert_eq!( + message(check(&format!("SET {long} = :v"))), + format!("Invalid UpdateExpression: {LEVELS_33}") + ); + assert!(message(check(&format!("SET #zz = :v, {long} = :v"))).contains("#zz")); + assert!(message(check(&format!("SET {} = :v", path_text("#zz", 33)))).contains("#zz")); + assert!(message(check(&format!("SET {long} = :v, #zz = :v"))).contains("33")); + } +} diff --git a/crates/core/src/expression/projection.rs b/crates/core/src/expression/projection.rs index ee90d5201..5c918598c 100755 --- a/crates/core/src/expression/projection.rs +++ b/crates/core/src/expression/projection.rs @@ -158,6 +158,10 @@ pub(crate) fn resolve_paths( } } } + // The length after the path's names, as the service checks it. + if let Some(msg) = super::path_length::too_many_levels(path) { + return Err(DynamoDbError::ValidationException(msg)); + } resolved.push(elems); } Ok(resolved) diff --git a/crates/core/src/expression/semantics/condition_rules.rs b/crates/core/src/expression/semantics/condition_rules.rs index a7a55ce93..f53ffa62a 100644 --- a/crates/core/src/expression/semantics/condition_rules.rs +++ b/crates/core/src/expression/semantics/condition_rules.rs @@ -57,7 +57,13 @@ fn walk_condition( walk_condition_operand(operand, maps, kind)?; walk_condition_operand(low, maps, kind)?; walk_condition_operand(high, maps, kind)?; - validate_ordering_operand_types(expr, maps).map_err(|e| with_kind(e, kind)) + validate_ordering_operand_types(expr, maps).map_err(|e| with_kind(e, kind))?; + // The bounds are checked at request time, so a condition that + // fails them is refused before the other expressions are checked. + match super::super::evaluator::between_bounds_fault(low, high, maps) { + Some(msg) => Err(validation(kind, &msg)), + None => Ok(()), + } } Expr::In { operand, list } => { walk_condition_operand(operand, maps, kind)?; diff --git a/crates/core/src/expression/semantics/refs.rs b/crates/core/src/expression/semantics/refs.rs index 157e60573..e3f7a4433 100644 --- a/crates/core/src/expression/semantics/refs.rs +++ b/crates/core/src/expression/semantics/refs.rs @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 //! The name and value usage checks shared by both passes: every `#name` -//! element of a document path, and every `:value` placeholder, is defined. +//! element of a document path, and every `:value` placeholder, is defined, +//! and a document path has at most 32 elements. use super::super::ast::PathElement; use super::super::kind::ExpressionKind; @@ -28,7 +29,12 @@ pub(super) fn check_path_refs( )); } } - Ok(()) + // The service checks the length after the path's names (measured + // 2026-10-26). + match super::super::path_length::too_many_levels(path) { + Some(msg) => Err(validation(kind, &msg)), + None => Ok(()), + } } pub(super) fn check_value_defined( diff --git a/crates/engine/src/put_item.rs b/crates/engine/src/put_item.rs index b51d54729..1d9991876 100755 --- a/crates/engine/src/put_item.rs +++ b/crates/engine/src/put_item.rs @@ -131,6 +131,19 @@ pub async fn handle_put_item( extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?; + // The nesting-depth limit applies to every ExpressionAttributeValue on the + // single-item write paths, condition-only placeholders included: measured + // 2026-08-24 (us-east-1), a 32-level value referenced solely by a + // ConditionExpression is rejected on PutItem, DeleteItem, and UpdateItem + // alike, while 31 levels is accepted. TransactWriteItems deliberately does + // NOT get this check: the same 32-level condition-only value is accepted + // inside a transaction on every sub-op shape (also measured). It comes + // before the ConditionExpression and the table lookup (measured + // 2026-10-26). + if let Some(values) = &input.expression_attribute_values { + extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; + } + // Validate expressions/`Expected` before the existence check, // so a malformed request to a missing table returns ValidationException, // not ResourceNotFoundException. Key/item-content checks stay after. @@ -177,17 +190,6 @@ pub async fn handle_put_item( &key_info.attribute_definitions, )?; - // The nesting-depth limit applies to every ExpressionAttributeValue on the - // single-item write paths, condition-only placeholders included: measured - // 2026-08-24 (us-east-1), a 32-level value referenced solely by a - // ConditionExpression is rejected on PutItem, DeleteItem, and UpdateItem - // alike, while 31 levels is accepted. TransactWriteItems deliberately does - // NOT get this check: the same 32-level condition-only value is accepted - // inside a transaction on every sub-op shape (also measured). - if let Some(values) = &input.expression_attribute_values { - extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; - } - extenddb_core::validation::validate_vector_write( &input.item, &key_info.vector_indexes, diff --git a/crates/engine/src/update_item.rs b/crates/engine/src/update_item.rs index 298a9948d..91a090e01 100755 --- a/crates/engine/src/update_item.rs +++ b/crates/engine/src/update_item.rs @@ -62,6 +62,20 @@ pub async fn handle_update_item( extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?; + // Amazon DynamoDB enforces the nesting-depth limit on EVERY + // ExpressionAttributeValue, condition-only placeholders included: a + // 32-level value referenced solely by a ConditionExpression answers the + // nesting ValidationException rather than evaluating the condition. + // An earlier measurement had condition-only values exempt, and some + // regions did behave that way; re-measured 2026-08-24, both us-east-1 and + // eu-west-2 apply the limit to the whole map, so the per-placeholder walk + // this replaced is gone. A 31-level value still passes and the condition + // evaluates normally. It comes before every expression check, the unused + // names and values, the table lookup and the key (measured 2026-10-26). + if let Some(values) = &input.expression_attribute_values { + extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; + } + // Validate Update/Condition expressions (and AttributeUpdates // desugaring) before the existence check; key, item-content nesting, and // the no-key-update checks stay after (post-existence on Amazon DynamoDB). @@ -209,19 +223,6 @@ pub async fn handle_update_item( // `expression::apply_update_validated`, which every backend applies to the // evaluated image and which no expression form can bypass. - // Amazon DynamoDB enforces the nesting-depth limit on EVERY - // ExpressionAttributeValue, condition-only placeholders included: a - // 32-level value referenced solely by a ConditionExpression answers the - // nesting ValidationException rather than evaluating the condition. - // An earlier measurement had condition-only values exempt, and some - // regions did behave that way; re-measured 2026-08-24, both us-east-1 and - // eu-west-2 apply the limit to the whole map, so the per-placeholder walk - // this replaced is gone. A 31-level value still passes and the condition - // evaluates normally. - if let Some(values) = &input.expression_attribute_values { - extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; - } - // Validate that no update action targets a key attribute (REQ-DATA-003) validate_no_key_updates(&actions, &key_info, &maps)?; diff --git a/docs/dynamodb-limits.md b/docs/dynamodb-limits.md index 2b37b9023..b693e40c1 100755 --- a/docs/dynamodb-limits.md +++ b/docs/dynamodb-limits.md @@ -67,6 +67,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Condition expression size | 4 KB | Enforced | `check_expression_size`, `LimitsConfig::max_expression_length_bytes` | | Update expression size | 4 KB | Enforced | `check_expression_size`, `LimitsConfig::max_expression_length_bytes` | | Key condition expression size | 4 KB | Enforced | `check_expression_size`, `LimitsConfig::max_expression_length_bytes` | +| Document path length in an expression | 32 elements (each attribute name and list index counts as one) | Enforced | Every expression kind: `Invalid : The document path has too many nesting levels; nesting levels: `. Checked path by path in the order the paths appear, after each path's `#name` references, so an undefined `#name` in an earlier path or inside the long path is reported first, as on the service. A syntax error anywhere in the expression is reported first. ExtendDB checks reserved words on the whole expression before any path, where the service checks them in the same walk, so a long path followed by a reserved word answers the reserved word error | | Expression attribute names | 2 MB total | Not enforced | Per-entry key/value validated (`serde_helpers`); no aggregate size validation | | Expression attribute values | 2 MB total | Not enforced | Per-entry key/value validated (`serde_helpers`); no aggregate size validation | From 5b64ffde00ed91c38404e40c1910741ff5209d50 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:43:46 +0000 Subject: [PATCH 2/4] test(expression): document path length limit and its order Dual-target wire tests from the 2026-10-26 measurements: 32 elements accepted and 33 or more refused in every expression kind and operation; an undefined `#name` before or inside a long path, and a filter or condition fault, reported before the length; a value nested too deep reported before a long path or a syntax error; BETWEEN bounds refused on a missing key, after a false AND and on an empty Query. The at-the-limit condition uses `size()` of the 32-element path. PutItem, UpdateItem and DeleteItem prefix expression errors with `1 validation error detected: `, so those tests accept the message either way. --- tests/test_document_path_length.py | 361 +++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/test_document_path_length.py diff --git a/tests/test_document_path_length.py b/tests/test_document_path_length.py new file mode 100644 index 000000000..d857b9407 --- /dev/null +++ b/tests/test_document_path_length.py @@ -0,0 +1,361 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Length limit of a document path in an expression. + +Amazon DynamoDB refuses a document path of more than 32 elements (each +attribute name and each list index counts as one) in every expression kind, +with ValidationException ``Invalid : The document path has too many +nesting levels; nesting levels: ``, naming the first such path in the +expression. A syntax error anywhere in the expression is reported instead. +The service checks a path's ``#name`` references before its length, path by +path in the order the paths appear, and finishes the checks of the +FilterExpression before the ProjectionExpression and of the +ConditionExpression before the UpdateExpression. Measured on 2026-10-26 at +02:26, 02:29, 03:38, 03:39, 04:21, 04:40, 04:41 and 04:43 UTC (us-east-1), each case +twice. + +PutItem, UpdateItem and DeleteItem prefix every expression error with +``1 validation error detected: ``; the other operations do not. That prefix +is a separate change, so for those three operations the tests accept the +message with or without it. + +Dual-target against Amazon DynamoDB and extenddb. +""" + +from __future__ import annotations + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +N = {"#m": "m", "#k": "k"} +S = {":s": {"S": "s"}} +PREFIX = "1 validation error detected: " + + +def too_long(kind: str, n: int) -> str: + return f"Invalid {kind}: The document path has too many nesting levels; nesting levels: {n}" + + +def p(n: int) -> str: + """A path of n elements: #m then n - 1 times .#k.""" + return "#m" + ".#k" * (n - 1) + + +def nmap(wrappers: int) -> dict: + value = {"S": "leaf"} + for _ in range(wrappers): + value = {"M": {"k": value}} + return value + + +def _error(call) -> tuple[str, str]: + with pytest.raises(ClientError) as exc_info: + call() + err = exc_info.value.response["Error"] + return err["Code"], err["Message"] + + +def _single_item_error(call) -> tuple[str, str]: + code, message = _error(call) + return code, message.removeprefix(PREFIX) + + +@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 + + +@pytest.fixture +def key(dynamodb_client, table, request): + k = {"pk": {"S": "plen"}, "sk": {"S": request.node.name}} + dynamodb_client.put_item(TableName=table, Item={**k, "x": {"S": "x"}, "m": nmap(31)}) + return k + + +def _stored(client, table, key): + return client.get_item(TableName=table, Key=key, ConsistentRead=True)["Item"] + + +def _update(client, table, key, expr, names=N, values=S, **kw): + args = {"TableName": table, "Key": key, "UpdateExpression": expr, **kw} + if names: + args["ExpressionAttributeNames"] = names + if values: + args["ExpressionAttributeValues"] = values + return client.update_item(**args) + + +# (case, update expression, names, values, elements reported) +UPDATE_REFUSED = [ + ("set_33", f"SET {p(33)} = :s", N, S, 33), + ("set_34", f"SET {p(34)} = :s", N, S, 34), + ("set_40", f"SET {p(40)} = :s", N, S, 40), + ("set_index_33", "SET #m" + "[0]" * 32 + " = :s", {"#m": "m"}, S, 33), + ("set_mixed_33", "SET #m" + ".#k[0]" * 16 + " = :s", N, S, 33), + ("first_of_33_and_40", f"SET {p(33)} = :s, {p(40)} = :s", N, S, 33), + ("first_of_40_and_33", f"SET {p(40)} = :s, {p(33)} = :s", N, S, 40), + ("value_side", f"SET #x = {p(33)}", {**N, "#x": "x"}, None, 33), + ("if_not_exists", f"SET #x = if_not_exists({p(33)}, :s)", {**N, "#x": "x"}, S, 33), + ("list_append", f"SET #x = list_append({p(33)}, :l)", {**N, "#x": "x"}, {":l": {"L": []}}, 33), + ("remove", f"REMOVE {p(33)}", N, None, 33), + ("delete", f"DELETE {p(33)} :ss", N, {":ss": {"SS": ["a"]}}, 33), + ("add", f"ADD {p(33)} :one", N, {":one": {"N": "1"}}, 33), + ("undefined_name_after", f"SET {p(33)} = :s, #zz = :s", N, S, 33), + ("undefined_value", f"SET {p(33)} = :nope", N, S, 33), + ("unused_value", f"SET {p(33)} = :s", N, {**S, ":unused": {"S": "u"}}, 33), + ("unused_name", f"SET {p(33)} = :s", {**N, "#unused": "u"}, S, 33), + ("overlapping_paths", f"SET {p(33)} = :s, {p(33)} = :s", N, S, 33), +] + + +@pytest.mark.parametrize(("case", "expr", "names", "values", "n"), UPDATE_REFUSED, + ids=[c[0] for c in UPDATE_REFUSED]) +def test_update_expression_path_past_the_limit(dynamodb_client, table, key, case, expr, names, values, n): + assert _single_item_error(lambda: _update(dynamodb_client, table, key, expr, names, values)) == ( + "ValidationException", too_long("UpdateExpression", n)) + assert _stored(dynamodb_client, table, key)["x"] == {"S": "x"} + + +def test_update_expression_path_at_the_limit(dynamodb_client, table, key): + _update(dynamodb_client, table, key, f"SET {p(32)} = :s") + item = _stored(dynamodb_client, table, key) + leaf = item["m"] + for _ in range(31): + leaf = leaf["M"]["k"] + assert leaf == {"S": "s"} + + +def test_a_syntax_error_after_a_long_path_comes_first(dynamodb_client, table, key): + # The service answers `Syntax error; token: "", near: ","`; ExtendDB + # words its syntax errors differently, so only the kind of error is compared. + code, message = _single_item_error(lambda: _update(dynamodb_client, table, key, f"SET {p(33)} = :s,")) + assert code == "ValidationException" + assert message.startswith("Invalid UpdateExpression: "), message + assert "nesting levels" not in message, message + + +def test_condition_expression_on_update_item(dynamodb_client, table, key): + assert _single_item_error(lambda: _update( + dynamodb_client, table, key, "SET #x = :s", names={**N, "#x": "x"}, + values={**S, ":z": {"N": "0"}}, ConditionExpression=f"size({p(33)}) > :z", + )) == ("ValidationException", too_long("ConditionExpression", 33)) + + +def test_condition_expression_on_put_item(dynamodb_client, table, key): + assert _single_item_error(lambda: dynamodb_client.put_item( + TableName=table, Item={**key, "x": {"S": "y"}}, + ConditionExpression=f"attribute_not_exists({p(33)})", ExpressionAttributeNames=N, + )) == ("ValidationException", too_long("ConditionExpression", 33)) + assert _stored(dynamodb_client, table, key)["x"] == {"S": "x"} + + +def test_condition_expression_on_delete_item(dynamodb_client, table, key): + assert _single_item_error(lambda: dynamodb_client.delete_item( + TableName=table, Key=key, ConditionExpression=f"attribute_exists({p(33)})", ExpressionAttributeNames=N, + )) == ("ValidationException", too_long("ConditionExpression", 33)) + assert "Item" in dynamodb_client.get_item(TableName=table, Key=key, ConsistentRead=True) + + +def test_condition_expression_at_the_limit(dynamodb_client, table, key): + # size() of the 32-element path is the 4 characters of the leaf string. + _update(dynamodb_client, table, key, "SET #x = :s", names={**N, "#x": "x"}, + values={**S, ":four": {"N": "4"}}, + ConditionExpression=f"attribute_exists(#x) AND size({p(32)}) = :four") + assert _stored(dynamodb_client, table, key)["x"] == {"S": "s"} + + +UNDEFINED_NAME = "An expression attribute name used in the document path is not defined; attribute name: #zz" +UNDEFINED_VALUE = "An expression attribute value used in expression is not defined; attribute value: :nope" +X = {**N, "#x": "x"} + + +# (case, update expression, names, values, condition, the message without its prefix) +SINGLE_ITEM_ORDER = [ + ("name_in_earlier_path", f"SET #zz = :s, {p(33)} = :s", N, S, None, f"Invalid UpdateExpression: {UNDEFINED_NAME}"), + ("name_first_in_long_path", "SET #zz" + ".#k" * 32 + " = :s", N, S, None, + f"Invalid UpdateExpression: {UNDEFINED_NAME}"), + ("name_last_in_long_path", f"SET {p(32)}.#zz = :s", N, S, None, f"Invalid UpdateExpression: {UNDEFINED_NAME}"), + ("condition_name_before_update", f"SET {p(33)} = :s", N, S, "attribute_exists(#zz)", + f"Invalid ConditionExpression: {UNDEFINED_NAME}"), + ("condition_value_before_update", f"SET {p(33)} = :s", X, S, "#x = :nope", + f"Invalid ConditionExpression: {UNDEFINED_VALUE}"), + ("condition_between_before_update", f"SET {p(33)} = :s", X, {**S, ":hi": {"N": "5"}, ":lo": {"N": "1"}}, + "#x BETWEEN :hi AND :lo", + "Invalid ConditionExpression: The BETWEEN operator requires upper bound to be greater than or equal to " + "lower bound; lower bound operand: AttributeValue: {N:5}, upper bound operand: AttributeValue: {N:1}"), + ("condition_operand_before_update", f"SET {p(33)} = :s", X, {**S, ":n": {"N": "1"}}, "begins_with(#x, :n)", + "Invalid ConditionExpression: Incorrect operand type for operator or function; operator or function: " + "begins_with, operand type: N"), +] + + +@pytest.mark.parametrize(("case", "expr", "names", "values", "condition", "message"), SINGLE_ITEM_ORDER, + ids=[c[0] for c in SINGLE_ITEM_ORDER]) +def test_update_item_reports_an_earlier_fault_first(dynamodb_client, table, key, case, expr, names, values, + condition, message): + kw = {"ConditionExpression": condition} if condition else {} + assert _single_item_error(lambda: _update(dynamodb_client, table, key, expr, names, values, **kw)) == ( + "ValidationException", message) + + +NESTING = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit" +BETWEEN = ("The BETWEEN operator requires upper bound to be greater than or equal to lower bound; " + "lower bound operand: AttributeValue: {N:5}, upper bound operand: AttributeValue: {N:1}") + + +def deep_value() -> dict: + return nmap(32) + + +def test_a_value_nested_too_deep_is_reported_first(dynamodb_client, table, key): + assert _single_item_error(lambda: _update(dynamodb_client, table, key, f"SET {p(33)} = :d", + values={":d": deep_value()})) == ("ValidationException", NESTING) + assert _single_item_error(lambda: dynamodb_client.put_item( + TableName=table, Item={**key, "x": {"S": "y"}}, + ConditionExpression=f"attribute_exists({p(33)}) OR #m <> :d", ExpressionAttributeNames=N, + ExpressionAttributeValues={":d": deep_value()}, + )) == ("ValidationException", NESTING) + assert _single_item_error(lambda: _update(dynamodb_client, table, key, "SET #m = :d,", names={"#m": "m"}, + values={":d": deep_value()})) == ("ValidationException", NESTING) + + +def test_between_bounds_are_checked_whatever_the_item(dynamodb_client, table, key): + bounds = {":hi": {"N": "5"}, ":lo": {"N": "1"}, ":s": {"S": "s"}} + missing = {"pk": {"S": "plen"}, "sk": {"S": "between-no-item"}} + for k, cond in ((missing, "#x BETWEEN :hi AND :lo"), (key, "attribute_exists(#n) AND #x BETWEEN :hi AND :lo")): + names = {"#x": "x", "#n": "nope"} if "#n" in cond else {"#x": "x"} + assert _single_item_error(lambda: _update(dynamodb_client, table, k, "SET #x = :s", names=names, + values=bounds, ConditionExpression=cond)) == ( + "ValidationException", f"Invalid ConditionExpression: {BETWEEN}"), cond + assert "Item" not in dynamodb_client.get_item(TableName=table, Key=missing, ConsistentRead=True) + assert _error(lambda: dynamodb_client.query( + TableName=table, KeyConditionExpression="pk = :p", FilterExpression="#x BETWEEN :hi AND :lo", + ExpressionAttributeNames={"#x": "x"}, + ExpressionAttributeValues={":p": {"S": "plen-empty"}, ":hi": bounds[":hi"], ":lo": bounds[":lo"]}, + )) == ("ValidationException", f"Invalid FilterExpression: {BETWEEN}") + + +def test_projection_names_come_before_the_length(dynamodb_client, table, key): + for projection in (f"#zz, {p(33)}", "#zz" + ".#k" * 32, f"{p(32)}.#zz"): + assert _error(lambda: dynamodb_client.get_item( + TableName=table, Key=key, ProjectionExpression=projection, ExpressionAttributeNames=N, + )) == ("ValidationException", f"Invalid ProjectionExpression: {UNDEFINED_NAME}"), projection + assert _error(lambda: dynamodb_client.transact_get_items(TransactItems=[ + {"Get": {"TableName": table, "Key": key, "ProjectionExpression": "#zz"}}, + {"Get": {"TableName": table, "Key": key, "ProjectionExpression": p(33), "ExpressionAttributeNames": N}}, + ])) == ("ValidationException", f"Invalid ProjectionExpression: {UNDEFINED_NAME}") + + +def test_transact_write_items_update(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.transact_write_items(TransactItems=[{"Update": { + "TableName": table, "Key": key, "UpdateExpression": f"SET {p(33)} = :s", + "ExpressionAttributeNames": N, "ExpressionAttributeValues": S, + }}])) == ("ValidationException", too_long("UpdateExpression", 33)) + + +def test_transact_write_items_condition_check(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.transact_write_items(TransactItems=[{"ConditionCheck": { + "TableName": table, "Key": key, "ConditionExpression": f"attribute_exists({p(33)})", + "ExpressionAttributeNames": N, + }}])) == ("ValidationException", too_long("ConditionExpression", 33)) + + +def test_transact_write_items_put_condition(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.transact_write_items(TransactItems=[{"Put": { + "TableName": table, "Item": {**key, "x": {"S": "y"}}, + "ConditionExpression": f"attribute_not_exists({p(33)})", "ExpressionAttributeNames": N, + }}])) == ("ValidationException", too_long("ConditionExpression", 33)) + assert _stored(dynamodb_client, table, key)["x"] == {"S": "x"} + + +def test_get_item_projection(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.get_item( + TableName=table, Key=key, ProjectionExpression=p(33), ExpressionAttributeNames=N, + )) == ("ValidationException", too_long("ProjectionExpression", 33)) + assert _error(lambda: dynamodb_client.get_item( + TableName=table, Key=key, ProjectionExpression="#m" + "[0]" * 32, ExpressionAttributeNames={"#m": "m"}, + )) == ("ValidationException", too_long("ProjectionExpression", 33)) + + +def test_get_item_projection_at_the_limit(dynamodb_client, table, key): + item = dynamodb_client.get_item(TableName=table, Key=key, ProjectionExpression=p(32), + ExpressionAttributeNames=N)["Item"] + assert set(item) == {"m"} + + +def test_batch_get_item_projection(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.batch_get_item(RequestItems={table: { + "Keys": [key], "ProjectionExpression": p(33), "ExpressionAttributeNames": N, + }})) == ("ValidationException", too_long("ProjectionExpression", 33)) + + +def test_transact_get_items_projection(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.transact_get_items(TransactItems=[{"Get": { + "TableName": table, "Key": key, "ProjectionExpression": p(33), "ExpressionAttributeNames": N, + }}])) == ("ValidationException", too_long("ProjectionExpression", 33)) + + +def _query(client, table, **kw): + values = {":p": {"S": "plen"}, **kw.pop("values", {})} + return client.query(TableName=table, KeyConditionExpression=kw.pop("key", "pk = :p"), + ExpressionAttributeNames=N, ExpressionAttributeValues=values, **kw) + + +def test_query_filter(dynamodb_client, table, key): + assert _error(lambda: _query(dynamodb_client, table, FilterExpression=f"attribute_exists({p(33)})")) == ( + "ValidationException", too_long("FilterExpression", 33)) + assert _query(dynamodb_client, table, FilterExpression=f"attribute_exists({p(32)})")["Count"] >= 1 + + +def test_query_projection(dynamodb_client, table, key): + assert _error(lambda: _query(dynamodb_client, table, ProjectionExpression=p(33))) == ( + "ValidationException", too_long("ProjectionExpression", 33)) + + +def test_query_key_condition(dynamodb_client, table, key): + assert _error(lambda: _query(dynamodb_client, table, key=f"pk = :p AND {p(33)} = :s", values=S)) == ( + "ValidationException", too_long("KeyConditionExpression", 33)) + assert _error(lambda: _query(dynamodb_client, table, key=f"pk = :p AND {p(32)} = :s", values=S)) == ( + "ValidationException", + "Invalid KeyConditionExpression: KeyConditionExpressions cannot have conditions on nested attributes") + + +def test_query_filter_is_checked_before_the_projection(dynamodb_client, table, key): + assert _error(lambda: _query(dynamodb_client, table, + FilterExpression=f"attribute_exists(#zz) OR attribute_exists({p(33)})")) == ( + "ValidationException", f"Invalid FilterExpression: {UNDEFINED_NAME}") + assert _error(lambda: _query(dynamodb_client, table, ProjectionExpression=p(33), + FilterExpression="attribute_exists(#zz)")) == ( + "ValidationException", f"Invalid FilterExpression: {UNDEFINED_NAME}") + + +def test_scan_filter_is_checked_before_the_projection(dynamodb_client, table, key): + for flt, names, message in (("attribute_exists(#zz)", N, UNDEFINED_NAME), ("#x = :nope", X, UNDEFINED_VALUE)): + assert _error(lambda: dynamodb_client.scan( + TableName=table, Limit=1, ProjectionExpression=p(33), FilterExpression=flt, + ExpressionAttributeNames=names, + )) == ("ValidationException", f"Invalid FilterExpression: {message}"), flt + + +def test_scan_filter_and_projection(dynamodb_client, table, key): + assert _error(lambda: dynamodb_client.scan( + TableName=table, Limit=1, FilterExpression=f"{p(33)} = :s", + ExpressionAttributeNames=N, ExpressionAttributeValues=S, + )) == ("ValidationException", too_long("FilterExpression", 33)) + assert _error(lambda: dynamodb_client.scan( + TableName=table, Limit=1, ProjectionExpression=p(33), ExpressionAttributeNames=N, + )) == ("ValidationException", too_long("ProjectionExpression", 33)) From ea464eba6d6d6141dd611557707eb37b38419791 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:43:46 +0000 Subject: [PATCH 3/4] fix(expression): value nesting after mixed parameters and Item numbers The service reports an invalid number in the Item or the Key, then legacy and expression parameters used together, before an ExpressionAttributeValue nested more than 32 levels (measured 2026-10-26 at 05:15 and 05:20 UTC, each request twice). The earlier move put the nesting check ahead of both: UpdateItem now runs it right after the check for mixed parameters, and PutItem checks the Item's numbers just before it. DeleteItem already ran it after the mixed-parameter check. The path_length module comment now says the key condition parser checks the length before the names are checked, and a unit test covers the BETWEEN bounds in the semantic walk. --- crates/core/src/expression/path_length.rs | 6 ++-- crates/core/src/expression/semantics/tests.rs | 22 ++++++++++++++ crates/engine/src/put_item.rs | 6 ++-- crates/engine/src/update_item.rs | 29 ++++++++++--------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/core/src/expression/path_length.rs b/crates/core/src/expression/path_length.rs index bfa33aff1..1db4d21c1 100644 --- a/crates/core/src/expression/path_length.rs +++ b/crates/core/src/expression/path_length.rs @@ -9,8 +9,10 @@ //! nesting levels: `. It checks a path's `#name` references first and then //! its length, path by path in the order the paths appear, so an undefined //! `#name` in an earlier path or inside the long path is reported instead -//! (measured 2026-10-26, us-east-1). The callers run the check where they -//! resolve each path's names. +//! (measured 2026-10-26, us-east-1). The semantic walk and the projection +//! resolution run the check right after each path's names; the key condition +//! parser runs it before the names are checked, ahead of its nested-attribute +//! refusal. use super::ast::PathElement; diff --git a/crates/core/src/expression/semantics/tests.rs b/crates/core/src/expression/semantics/tests.rs index 52aae7b06..a39f64f49 100644 --- a/crates/core/src/expression/semantics/tests.rs +++ b/crates/core/src/expression/semantics/tests.rs @@ -432,3 +432,25 @@ fn key_condition_function_names() { } assert_eq!(key_condition_err("pk = :p AND begins_with(sk, :v)"), "OK"); } + +/// The BETWEEN bounds are checked at request time (measured 2026-10-26, +/// HBTW-1: refused on a missing key and after a false AND). +#[test] +fn between_bounds_are_checked_in_the_walk() { + let m = maps( + &[], + &[ + (":hi", AttributeValue::N("5".to_owned())), + (":lo", AttributeValue::N("1".to_owned())), + ], + ); + assert_eq!( + condition_err("a BETWEEN :hi AND :lo", &m), + format!( + "{C}The BETWEEN operator requires upper bound to be greater than or equal to lower \ + bound; lower bound operand: AttributeValue: {{N:5}}, upper bound operand: \ + AttributeValue: {{N:1}}" + ) + ); + assert!(condition_err("attribute_exists(b) AND a BETWEEN :hi AND :lo", &m).contains("BETWEEN")); +} diff --git a/crates/engine/src/put_item.rs b/crates/engine/src/put_item.rs index 1d9991876..82c2f758f 100755 --- a/crates/engine/src/put_item.rs +++ b/crates/engine/src/put_item.rs @@ -138,9 +138,11 @@ pub async fn handle_put_item( // alike, while 31 levels is accepted. TransactWriteItems deliberately does // NOT get this check: the same 32-level condition-only value is accepted // inside a transaction on every sub-op shape (also measured). It comes - // before the ConditionExpression and the table lookup (measured - // 2026-10-26). + // after an invalid number in the Item and before the ConditionExpression + // and the table lookup (measured 2026-10-26), so the Item's numbers are + // checked here first; `validate_put_item` checks them again later. if let Some(values) = &input.expression_attribute_values { + extenddb_core::validation::validate_item_numbers(&input.item)?; extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; } diff --git a/crates/engine/src/update_item.rs b/crates/engine/src/update_item.rs index 91a090e01..e6576c2f2 100755 --- a/crates/engine/src/update_item.rs +++ b/crates/engine/src/update_item.rs @@ -62,20 +62,6 @@ pub async fn handle_update_item( extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?; - // Amazon DynamoDB enforces the nesting-depth limit on EVERY - // ExpressionAttributeValue, condition-only placeholders included: a - // 32-level value referenced solely by a ConditionExpression answers the - // nesting ValidationException rather than evaluating the condition. - // An earlier measurement had condition-only values exempt, and some - // regions did behave that way; re-measured 2026-08-24, both us-east-1 and - // eu-west-2 apply the limit to the whole map, so the per-placeholder walk - // this replaced is gone. A 31-level value still passes and the condition - // evaluates normally. It comes before every expression check, the unused - // names and values, the table lookup and the key (measured 2026-10-26). - if let Some(values) = &input.expression_attribute_values { - extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; - } - // Validate Update/Condition expressions (and AttributeUpdates // desugaring) before the existence check; key, item-content nesting, and // the no-key-update checks stay after (post-existence on Amazon DynamoDB). @@ -103,6 +89,21 @@ pub async fn handle_update_item( ], )?; + // Amazon DynamoDB enforces the nesting-depth limit on EVERY + // ExpressionAttributeValue, condition-only placeholders included: a + // 32-level value referenced solely by a ConditionExpression answers the + // nesting ValidationException rather than evaluating the condition. + // An earlier measurement had condition-only values exempt, and some + // regions did behave that way; re-measured 2026-08-24, both us-east-1 and + // eu-west-2 apply the limit to the whole map, so the per-placeholder walk + // this replaced is gone. A 31-level value still passes and the condition + // evaluates normally. It comes after the check for legacy and expression + // parameters used together and before the unused names and values, every + // expression check, the table lookup and the key (measured 2026-10-26). + if let Some(values) = &input.expression_attribute_values { + extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?; + } + // Desugar legacy AttributeUpdates into UpdateExpression if present. let (effective_update_expr, extra_expr_values, extra_expr_names) = if let Some(attr_updates) = &input.attribute_updates { From 8549bc023a608e3695f09c7b71038f1021610df5 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:43:46 +0000 Subject: [PATCH 4/4] test(expression): mixed parameters and Item numbers before value nesting UpdateItem with `AttributeUpdates`, `Expected` or `ConditionalOperator` beside an expression and a 32-level value answers the mixed-parameter error, and PutItem with `{N: abc}` in the Item, at the top level or nested, answers the number error (measured 2026-10-26 at 05:15 and 05:20 UTC). --- tests/test_document_path_length.py | 41 ++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/test_document_path_length.py b/tests/test_document_path_length.py index d857b9407..c993407e1 100644 --- a/tests/test_document_path_length.py +++ b/tests/test_document_path_length.py @@ -12,8 +12,8 @@ path in the order the paths appear, and finishes the checks of the FilterExpression before the ProjectionExpression and of the ConditionExpression before the UpdateExpression. Measured on 2026-10-26 at -02:26, 02:29, 03:38, 03:39, 04:21, 04:40, 04:41 and 04:43 UTC (us-east-1), each case -twice. +02:26, 02:29, 03:38, 03:39, 04:21, 04:40, 04:41, 04:43, 05:15 and 05:20 UTC +(us-east-1), each case twice. PutItem, UpdateItem and DeleteItem prefix every expression error with ``1 validation error detected: ``; the other operations do not. That prefix @@ -233,6 +233,43 @@ def test_a_value_nested_too_deep_is_reported_first(dynamodb_client, table, key): values={":d": deep_value()})) == ("ValidationException", NESTING) +MIXING = "Can not use both expression and non-expression parameters in the same request: " +BAD_NUMBER = "The parameter cannot be converted to a numeric value: abc" + + +# (case, extra UpdateItem arguments, non-expression and expression parameters named) +MIXED = [ + ("attribute_updates", {"UpdateExpression": "SET #m = :d", + "AttributeUpdates": {"y": {"Action": "PUT", "Value": {"S": "v"}}}}, + "{AttributeUpdates}", "{UpdateExpression}"), + ("expected", {"UpdateExpression": "SET #m = :d", "Expected": {"x": {"Exists": False}}}, + "{Expected}", "{UpdateExpression}"), + ("conditional_operator", {"UpdateExpression": "SET #m = :d", "ConditionalOperator": "AND"}, + "{ConditionalOperator}", "{UpdateExpression}"), + ("attribute_updates_and_condition", {"ConditionExpression": "#m <> :d", + "AttributeUpdates": {"y": {"Action": "PUT", "Value": {"S": "v"}}}}, + "{AttributeUpdates}", "{ConditionExpression}"), +] + + +@pytest.mark.parametrize(("case", "args", "legacy", "expression"), MIXED, ids=[c[0] for c in MIXED]) +def test_mixed_parameters_come_before_a_value_nested_too_deep(dynamodb_client, table, key, case, args, legacy, + expression): + assert _single_item_error(lambda: dynamodb_client.update_item( + TableName=table, Key=key, ExpressionAttributeNames={"#m": "m"}, + ExpressionAttributeValues={":d": deep_value()}, **args, + )) == ("ValidationException", + f"{MIXING}Non-expression parameters: {legacy} Expression parameters: {expression}") + + +@pytest.mark.parametrize("bad", [{"N": "abc"}, {"M": {"z": {"N": "abc"}}}], ids=["top_level", "nested"]) +def test_an_invalid_number_in_the_item_comes_before_a_value_nested_too_deep(dynamodb_client, table, key, bad): + assert _single_item_error(lambda: dynamodb_client.put_item( + TableName=table, Item={**key, "y": bad}, ConditionExpression="#m <> :d", + ExpressionAttributeNames={"#m": "m"}, ExpressionAttributeValues={":d": deep_value()}, + )) == ("ValidationException", BAD_NUMBER) + + def test_between_bounds_are_checked_whatever_the_item(dynamodb_client, table, key): bounds = {":hi": {"N": "5"}, ":lo": {"N": "1"}, ":s": {"S": "s"}} missing = {"pk": {"S": "plen"}, "sk": {"S": "between-no-item"}}