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..1db4d21c1 --- /dev/null +++ b/crates/core/src/expression/path_length.rs @@ -0,0 +1,131 @@ +// 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 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; + +/// 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/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 b51d54729..82c2f758f 100755 --- a/crates/engine/src/put_item.rs +++ b/crates/engine/src/put_item.rs @@ -131,6 +131,21 @@ 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 + // 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())?; + } + // 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 +192,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..e6576c2f2 100755 --- a/crates/engine/src/update_item.rs +++ b/crates/engine/src/update_item.rs @@ -89,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 { @@ -209,19 +224,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 | diff --git a/tests/test_document_path_length.py b/tests/test_document_path_length.py new file mode 100644 index 000000000..c993407e1 --- /dev/null +++ b/tests/test_document_path_length.py @@ -0,0 +1,398 @@ +# 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, 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 +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) + + +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"}} + 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))