From 8402b5b96d9f380fd8a3872d18c7454ccaf12d90 Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Thu, 22 Oct 2026 00:23:08 +0000 Subject: [PATCH] fix(limits): enforce attribute-name size on every write path with per-form wording An attribute name is at most 65,535 bytes of UTF-8, at every depth: a top-level name, a map key inside any value, and a name supplied through ExpressionAttributeNames. Measured against Amazon DynamoDB (us-east-1, 2026-10-21) at 65,535 and 65,536 bytes, the service refuses a longer name on PutItem, UpdateItem, DeleteItem, TransactWriteItems and BatchWriteItem with a ValidationException whose wording follows the request form: - PutItem for a name in the item, and UpdateItem for a map key inside an ExpressionAttributeValues value or an AttributeUpdates name or value: "1 validation error detected: Attribute name is too large, must be less than 65536 bytes". PutItem and DeleteItem use the same message for a map key inside a condition value. - PutItem, UpdateItem and DeleteItem for an ExpressionAttributeNames value: the same message followed by " for key #n", naming the placeholder. - Any member of TransactWriteItems (Put, Update, Delete, ConditionCheck) for an ExpressionAttributeNames value: "ExpressionAttributeNames contains invalid value: Attribute name is too large, must be less than 65536 bytes for key #n"; for a map key inside an ExpressionAttributeValues value: "ExpressionAttributeValues contains invalid value: Attribute name is too large, must be less than 65536 bytes for key :v". The whole request fails with no CancellationReasons. - The Put member of TransactWriteItems and a BatchWriteItem PutRequest, for a name in the item: "Attribute name is too large, must be less than 65536 bytes". The limit counts bytes: 32,768 two-byte characters are refused, and 32,767 of them plus one ASCII character are accepted. The service checks every ExpressionAttributeNames value, including one no expression uses. The name check runs ahead of the table lookup on every one of these requests. In a transaction it runs for every member before any member's table lookup, so a request whose first member names a missing table answers with a later member's name refusal; in a batch it runs for every PutRequest before the table lookup and the key check, so an item that is also missing its key answers with the name refusal. UpdateItem and a transacted Update also report the name ahead of an UpdateExpression syntax error, a failing condition and a secondary-index key type mismatch. With a long name in two places at once, a standalone UpdateItem reports the ExpressionAttributeNames placeholder before a map key in a value, and a standalone PutItem reports the item before the ExpressionAttributeNames placeholder; a transaction member reports a map key in a value first, then the ExpressionAttributeNames placeholder, then (for Put) the item. ExtendDB checked names only on the put paths (PutItem, BatchWriteItem PutRequest, TransactWriteItems Put and ImportTable), after the table lookup, with its own wording: "One or more parameter values were invalid: Size of attribute name '...' has exceeded the maximum size limit of 65535 bytes". UpdateItem and the Update member of TransactWriteItems stored a 65,536-byte name with HTTP 200, and a long name that arrived only through a condition's ExpressionAttributeNames was accepted on PutItem, DeleteItem and every transaction member. validate_attribute_name_sizes and the new validate_expression_attribute_name_sizes take an AttributeNameRequestForm (SingleItem, TransactionMember or BatchRequest), which selects the wording and the order of the two expression maps. validate_attribute_updates_name_sizes covers the legacy AttributeUpdates map. Each map is visited in sorted placeholder order, and the figure in the message is max_attribute_name_bytes plus one. PutItem, UpdateItem and DeleteItem run the checks before the table lookup. TransactWriteItems checks every member in a pass before prepare_write_op; BatchWriteItem checks every PutRequest in a pass before the per-table loop. The checks run in the engine, which all four storage backends share, so no backend code changes. The attribute name size row in docs/dynamodb-limits.md moves from Partial to Enforced and gives the wording and order for each form. tests/test_attribute_name_limit_on_update.py has 49 dual-target cases. They pass on servers built from this change on SQLite, PostgreSQL and MongoDB. The first 35 cases pass on the service; the 14 added for the condition maps, the missing-table and missing-key positions and the two-map orders have not yet been run on the service, and each one encodes a probe row the service answered. A 91-row probe of the same shapes gives the same status, code, message and stored item on the service and on each of the three servers, with one exception outside this change: the control row for an index-key type mismatch alone on UpdateItem, which PostgreSQL does not refuse on main. Removing, switching, swapping or moving each engine call site, and each wording and ordering branch in core, makes the cases that cover it fail. Core unit tests pin every message form, both map orders, the byte count of multi-byte names and the sorted placeholder order. Fixes #365. --- crates/core/src/validation/mod.rs | 589 ++++++++++++++-- crates/engine/src/batch_write_item.rs | 19 +- crates/engine/src/delete_item.rs | 10 + crates/engine/src/import_export.rs | 6 +- crates/engine/src/put_item.rs | 16 + crates/engine/src/transact_write_items.rs | 59 +- crates/engine/src/update_item.rs | 13 + docs/dynamodb-limits.md | 6 +- tests/test_attribute_name_limit_on_update.py | 690 +++++++++++++++++++ 9 files changed, 1352 insertions(+), 56 deletions(-) create mode 100644 tests/test_attribute_name_limit_on_update.py diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index 5b780fd6a..b661aba25 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -991,8 +991,9 @@ pub fn validate_put_item( )); } + // The attribute-name size check is not here: the service reports it + // ahead of the table lookup, so the handler runs it before this. validate_item_keys(&input.item, key_schema, attr_defs)?; - validate_attribute_name_sizes(&input.item, limits)?; validate_item_numbers(&input.item)?; validate_item_nesting_depth(&input.item)?; @@ -1122,59 +1123,234 @@ fn validate_expression_attribute_value_numbers( Ok(()) } +/// The request form an attribute name arrived in, which selects the wording +/// of the size refusal (REQ-LIM-004). +/// +/// Measured on the service (2026-10-21, us-east-1) with a 65,536-byte name: +/// +/// | Request form | Refusal | +/// |---|---| +/// | `PutItem` item, `UpdateItem` value map key or `AttributeUpdates` name | `1 validation error detected: Attribute name is too large, must be less than 65536 bytes` | +/// | `PutItem`, `UpdateItem` or `DeleteItem` `ExpressionAttributeNames` value | the same, followed by ` for key #n` | +/// | `TransactWriteItems` `Put` item, `BatchWriteItem` `PutRequest` item | `Attribute name is too large, must be less than 65536 bytes` | +/// | `TransactWriteItems` `Put`, `Update`, `Delete` or `ConditionCheck` `ExpressionAttributeNames` value | `ExpressionAttributeNames contains invalid value: Attribute name is too large, must be less than 65536 bytes for key #n` | +/// | `TransactWriteItems` `Put` or `Update` map key inside an `ExpressionAttributeValues` value | `ExpressionAttributeValues contains invalid value: Attribute name is too large, must be less than 65536 bytes for key :v` | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributeNameRequestForm { + /// A standalone `PutItem`, `UpdateItem` or `DeleteItem` request. + SingleItem, + /// A member of a `TransactWriteItems` request. + TransactionMember, + /// A `PutRequest` of a `BatchWriteItem` request. It carries no + /// expression maps. + BatchRequest, +} + /// Validate that no attribute name exceeds the maximum allowed size (REQ-LIM-004). /// +/// Top-level names and map keys at every depth are measured in UTF-8 bytes. +/// `form` selects the refusal wording (see [`AttributeNameRequestForm`]). +/// /// # Errors /// /// Returns `DynamoDbError::ValidationException` if any attribute name exceeds the limit. pub fn validate_attribute_name_sizes( item: &Item, limits: &LimitsConfig, + form: AttributeNameRequestForm, ) -> Result<(), DynamoDbError> { for (name, value) in item { - check_attribute_name_size(name, limits)?; - check_nested_attribute_name_sizes(value, limits)?; + check_attribute_name_size(name, limits, form)?; + check_nested_attribute_name_sizes(value, limits, form)?; } Ok(()) } -fn check_attribute_name_size(name: &str, limits: &LimitsConfig) -> Result<(), DynamoDbError> { - if name.len() > limits.max_attribute_name_bytes { - return Err(DynamoDbError::ValidationException(format!( - "One or more parameter values were invalid: Size of attribute name '{}' \ - has exceeded the maximum size limit of {} bytes", - truncate_for_error(name), - limits.max_attribute_name_bytes - ))); +/// Validate the attribute names a request brings in through its expression +/// maps (REQ-LIM-004): `PutItem`, `UpdateItem`, `DeleteItem` and every +/// `TransactWriteItems` member. +/// +/// Every `ExpressionAttributeNames` value is checked, used or not, and the +/// refusal names the placeholder (`for key #n`). Every map key inside every +/// `ExpressionAttributeValues` value is checked at every depth; a standalone +/// request refuses it with the prefixed sentence alone, a transaction member +/// names the value placeholder (`for key :v`). Each map is visited in sorted +/// key order so the reported placeholder is deterministic. The order of the +/// two maps follows the request form, as measured on the service +/// (2026-10-21) with a long name in each map: a standalone `UpdateItem` +/// reports the `ExpressionAttributeNames` placeholder, a transacted `Update` +/// reports the `ExpressionAttributeValues` placeholder. Both checks are input +/// validation: callers run them ahead of the condition, the index-key checks +/// and any storage access. `form` selects the wording (see +/// [`AttributeNameRequestForm`]). +/// +/// # Errors +/// +/// Returns `DynamoDbError::ValidationException` for the first name over the limit. +pub fn validate_expression_attribute_name_sizes( + names: Option<&std::collections::HashMap>, + values: Option<&std::collections::HashMap>, + limits: &LimitsConfig, + form: AttributeNameRequestForm, +) -> Result<(), DynamoDbError> { + if form == AttributeNameRequestForm::SingleItem { + check_expression_attribute_names(names, limits, form)?; + check_expression_attribute_values(values, limits, form) + } else { + check_expression_attribute_values(values, limits, form)?; + check_expression_attribute_names(names, limits, form) } - Ok(()) } -fn check_nested_attribute_name_sizes( - value: &AttributeValue, +/// The `ExpressionAttributeNames` half of +/// [`validate_expression_attribute_name_sizes`]. +fn check_expression_attribute_names( + names: Option<&std::collections::HashMap>, limits: &LimitsConfig, + form: AttributeNameRequestForm, ) -> Result<(), DynamoDbError> { - match value { - AttributeValue::M(map) => { - for (name, nested) in map { - check_attribute_name_size(name, limits)?; - check_nested_attribute_name_sizes(nested, limits)?; + if let Some(names) = names { + let mut placeholders: Vec<&String> = names.keys().collect(); + placeholders.sort(); + for placeholder in placeholders { + if names[placeholder].len() > limits.max_attribute_name_bytes { + return Err(DynamoDbError::ValidationException( + expression_attribute_name_too_large( + limits, + form, + "ExpressionAttributeNames", + placeholder, + ), + )); } } - AttributeValue::L(list) => { - for nested in list { - check_nested_attribute_name_sizes(nested, limits)?; + } + Ok(()) +} + +/// The `ExpressionAttributeValues` half of +/// [`validate_expression_attribute_name_sizes`]. +fn check_expression_attribute_values( + values: Option<&std::collections::HashMap>, + limits: &LimitsConfig, + form: AttributeNameRequestForm, +) -> Result<(), DynamoDbError> { + if let Some(values) = values { + let mut placeholders: Vec<&String> = values.keys().collect(); + placeholders.sort(); + for placeholder in placeholders { + if form == AttributeNameRequestForm::SingleItem { + check_nested_attribute_name_sizes(&values[placeholder], limits, form)?; + } else if nested_attribute_name_too_large(&values[placeholder], limits) { + return Err(DynamoDbError::ValidationException( + expression_attribute_name_too_large( + limits, + form, + "ExpressionAttributeValues", + placeholder, + ), + )); } } - _ => {} } Ok(()) } -/// Truncate a string for inclusion in error messages. -fn truncate_for_error(s: &str) -> &str { - let end = s.char_indices().nth(64).map_or(s.len(), |(idx, _)| idx); - &s[..end] +/// Validate the attribute names of a legacy `AttributeUpdates` map: each +/// attribute it names and the map keys inside each supplied value, with the +/// standalone `UpdateItem` wording (REQ-LIM-004). +/// +/// # Errors +/// +/// Returns `DynamoDbError::ValidationException` for the first name over the limit. +pub fn validate_attribute_updates_name_sizes( + updates: &std::collections::HashMap, + limits: &LimitsConfig, +) -> Result<(), DynamoDbError> { + let form = AttributeNameRequestForm::SingleItem; + let mut names: Vec<&String> = updates.keys().collect(); + names.sort(); + for name in names { + check_attribute_name_size(name, limits, form)?; + if let Some(value) = &updates[name].value { + check_nested_attribute_name_sizes(value, limits, form)?; + } + } + Ok(()) +} + +/// The service's refusal for an attribute name over the limit, worded for +/// the request form. The figure is the first refused size, one more than the +/// configured maximum. +fn attribute_name_too_large(limits: &LimitsConfig, form: AttributeNameRequestForm) -> String { + let sentence = format!( + "Attribute name is too large, must be less than {} bytes", + limits.max_attribute_name_bytes.saturating_add(1) + ); + match form { + AttributeNameRequestForm::SingleItem => format!("1 validation error detected: {sentence}"), + AttributeNameRequestForm::TransactionMember | AttributeNameRequestForm::BatchRequest => { + sentence + } + } +} + +/// The refusal for an over-limit name that arrived through an expression +/// map, naming the placeholder. A standalone request uses the prefixed +/// sentence; a transaction member names the map the placeholder belongs to. +fn expression_attribute_name_too_large( + limits: &LimitsConfig, + form: AttributeNameRequestForm, + map: &str, + placeholder: &str, +) -> String { + let refusal = attribute_name_too_large(limits, form); + match form { + AttributeNameRequestForm::SingleItem => format!("{refusal} for key {placeholder}"), + AttributeNameRequestForm::TransactionMember | AttributeNameRequestForm::BatchRequest => { + format!("{map} contains invalid value: {refusal} for key {placeholder}") + } + } +} + +fn check_attribute_name_size( + name: &str, + limits: &LimitsConfig, + form: AttributeNameRequestForm, +) -> Result<(), DynamoDbError> { + if name.len() > limits.max_attribute_name_bytes { + return Err(DynamoDbError::ValidationException( + attribute_name_too_large(limits, form), + )); + } + Ok(()) +} + +fn check_nested_attribute_name_sizes( + value: &AttributeValue, + limits: &LimitsConfig, + form: AttributeNameRequestForm, +) -> Result<(), DynamoDbError> { + if nested_attribute_name_too_large(value, limits) { + return Err(DynamoDbError::ValidationException( + attribute_name_too_large(limits, form), + )); + } + Ok(()) +} + +/// Whether any map key inside `value`, at any depth, exceeds the limit. +fn nested_attribute_name_too_large(value: &AttributeValue, limits: &LimitsConfig) -> bool { + match value { + AttributeValue::M(map) => map.iter().any(|(name, nested)| { + name.len() > limits.max_attribute_name_bytes + || nested_attribute_name_too_large(nested, limits) + }), + AttributeValue::L(list) => list + .iter() + .any(|nested| nested_attribute_name_too_large(nested, limits)), + _ => false, + } } /// Validate that an item contains all required key attributes with correct types. @@ -2509,7 +2685,7 @@ mod tests { let limits = LimitsConfig::default(); let mut item = Item::new(); item.insert("ok_name".to_owned(), AttributeValue::S("v".to_owned())); - assert!(validate_attribute_name_sizes(&item, &limits).is_ok()); + assert!(validate_attribute_name_sizes(&item, &limits, SINGLE).is_ok()); } #[test] @@ -2574,10 +2750,10 @@ mod tests { }; let mut item = Item::new(); item.insert("a".repeat(11), AttributeValue::S("v".to_owned())); - let err = validate_attribute_name_sizes(&item, &limits).unwrap_err(); - assert!( - err.to_string().contains("Size of attribute name"), - "unexpected error: {err}" + let err = validate_attribute_name_sizes(&item, &limits, SINGLE).unwrap_err(); + assert_eq!( + err.to_string(), + "1 validation error detected: Attribute name is too large, must be less than 11 bytes" ); } @@ -2590,18 +2766,124 @@ mod tests { } } + /// The refusal under `short_name_limits`: the first refused size is 11. + const SHORT_TOO_LARGE: &str = + "1 validation error detected: Attribute name is too large, must be less than 11 bytes"; + + /// The same refusal without the prefix, as a transaction `Put` member and + /// a batch `PutRequest` word it. + const SHORT_TOO_LARGE_BARE: &str = "Attribute name is too large, must be less than 11 bytes"; + + const SINGLE: AttributeNameRequestForm = AttributeNameRequestForm::SingleItem; + const TRANSACTION: AttributeNameRequestForm = AttributeNameRequestForm::TransactionMember; + const BATCH: AttributeNameRequestForm = AttributeNameRequestForm::BatchRequest; + + #[test] + fn item_name_over_limit_in_transaction_and_batch_uses_the_bare_sentence() { + let mut top = Item::new(); + top.insert("a".repeat(11), AttributeValue::S("v".to_owned())); + let mut nested = Item::new(); + nested.insert("doc".to_owned(), map_with_key("k".repeat(11))); + for item in [top, nested] { + for form in [TRANSACTION, BATCH] { + let err = + validate_attribute_name_sizes(&item, &short_name_limits(), form).unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + assert_eq!(err.to_string(), SHORT_TOO_LARGE_BARE, "{form:?}"); + } + let err = + validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); + } + } + + #[test] + fn transaction_expression_name_over_limit_names_the_map_and_placeholder() { + let n = names(&[("#n", "a".repeat(11))]); + let err = validate_expression_attribute_name_sizes( + Some(&n), + None, + &short_name_limits(), + TRANSACTION, + ) + .unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + assert_eq!( + err.to_string(), + format!( + "ExpressionAttributeNames contains invalid value: {SHORT_TOO_LARGE_BARE} for key #n" + ) + ); + } + + #[test] + fn transaction_expression_value_map_key_over_limit_names_the_value_placeholder() { + let deep = { + let mut mid = Item::new(); + mid.insert("ok".to_owned(), map_with_key("k".repeat(11))); + AttributeValue::M(mid) + }; + let in_list = AttributeValue::L(vec![map_with_key("k".repeat(11))]); + for value in [map_with_key("k".repeat(11)), deep, in_list] { + let v = values(&[(":a", AttributeValue::S("x".to_owned())), (":v", value)]); + let err = validate_expression_attribute_name_sizes( + None, + Some(&v), + &short_name_limits(), + TRANSACTION, + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "ExpressionAttributeValues contains invalid value: {SHORT_TOO_LARGE_BARE} for key :v" + ) + ); + } + } + + #[test] + fn transaction_expression_values_are_checked_before_names() { + let n = names(&[("#n", "a".repeat(11))]); + let v = values(&[(":v", map_with_key("k".repeat(11)))]); + let err = validate_expression_attribute_name_sizes( + Some(&n), + Some(&v), + &short_name_limits(), + TRANSACTION, + ) + .unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "ExpressionAttributeValues contains invalid value: {SHORT_TOO_LARGE_BARE} for key :v" + ) + ); + } + + #[test] + fn transaction_expression_maps_within_limit_pass() { + let n = names(&[("#n", "a".repeat(10))]); + let v = values(&[(":v", map_with_key("k".repeat(10)))]); + assert!( + validate_expression_attribute_name_sizes( + Some(&n), + Some(&v), + &short_name_limits(), + TRANSACTION + ) + .is_ok() + ); + } + #[test] fn nested_map_key_exceeding_limit_rejected() { let mut inner = Item::new(); inner.insert("b".repeat(11), AttributeValue::S("v".to_owned())); let mut item = Item::new(); item.insert("doc".to_owned(), AttributeValue::M(inner)); - let err = validate_attribute_name_sizes(&item, &short_name_limits()).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains(&format!("Size of attribute name '{}'", "b".repeat(11))), - "error should name the nested key: {msg}" - ); + let err = validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); } #[test] @@ -2616,11 +2898,8 @@ mod tests { AttributeValue::M(inner), ]), ); - let err = validate_attribute_name_sizes(&item, &short_name_limits()).unwrap_err(); - assert!( - err.to_string().contains("Size of attribute name"), - "unexpected error: {err}" - ); + let err = validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); } #[test] @@ -2637,7 +2916,7 @@ mod tests { ); let mut item = Item::new(); item.insert("root".to_owned(), AttributeValue::M(top)); - assert!(validate_attribute_name_sizes(&item, &short_name_limits()).is_err()); + assert!(validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).is_err()); } #[test] @@ -2651,7 +2930,7 @@ mod tests { "rows".to_owned(), AttributeValue::L(vec![AttributeValue::M(inner)]), ); - assert!(validate_attribute_name_sizes(&item, &short_name_limits()).is_ok()); + assert!(validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).is_ok()); } #[test] @@ -2661,13 +2940,229 @@ mod tests { at_cap.insert("é".repeat(5), AttributeValue::Null); let mut item = Item::new(); item.insert("doc".to_owned(), AttributeValue::M(at_cap)); - assert!(validate_attribute_name_sizes(&item, &short_name_limits()).is_ok()); + assert!(validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).is_ok()); let mut over = Item::new(); over.insert("é".repeat(6), AttributeValue::Null); let mut item = Item::new(); item.insert("doc".to_owned(), AttributeValue::M(over)); - assert!(validate_attribute_name_sizes(&item, &short_name_limits()).is_err()); + assert!(validate_attribute_name_sizes(&item, &short_name_limits(), SINGLE).is_err()); + } + + #[test] + fn attribute_name_limit_message_at_default_limit() { + let limits = LimitsConfig::default(); + let mut item = Item::new(); + item.insert("a".repeat(65_535), AttributeValue::Null); + assert!(validate_attribute_name_sizes(&item, &limits, SINGLE).is_ok()); + + let mut item = Item::new(); + item.insert("a".repeat(65_536), AttributeValue::Null); + let err = validate_attribute_name_sizes(&item, &limits, SINGLE).unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + assert_eq!( + err.to_string(), + "1 validation error detected: Attribute name is too large, must be less than 65536 bytes" + ); + } + + fn names(pairs: &[(&str, String)]) -> std::collections::HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), v.clone())) + .collect() + } + + fn values( + pairs: &[(&str, AttributeValue)], + ) -> std::collections::HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), v.clone())) + .collect() + } + + fn map_with_key(key: String) -> AttributeValue { + let mut inner = Item::new(); + inner.insert(key, AttributeValue::S("v".to_owned())); + AttributeValue::M(inner) + } + + #[test] + fn expression_name_over_limit_names_the_placeholder() { + let n = names(&[("#n", "a".repeat(11))]); + let err = + validate_expression_attribute_name_sizes(Some(&n), None, &short_name_limits(), SINGLE) + .unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + assert_eq!(err.to_string(), format!("{SHORT_TOO_LARGE} for key #n")); + } + + #[test] + fn expression_name_at_limit_passes() { + let n = names(&[("#n", "a".repeat(10)), ("#m", "short".to_owned())]); + assert!( + validate_expression_attribute_name_sizes(Some(&n), None, &short_name_limits(), SINGLE) + .is_ok() + ); + } + + #[test] + fn expression_name_limit_counts_utf8_bytes() { + // Five two-byte characters is ten bytes: at the cap. Six is over. + let at_cap = names(&[("#n", "é".repeat(5))]); + assert!( + validate_expression_attribute_name_sizes( + Some(&at_cap), + None, + &short_name_limits(), + SINGLE + ) + .is_ok() + ); + let over = names(&[("#n", "é".repeat(6))]); + assert!( + validate_expression_attribute_name_sizes( + Some(&over), + None, + &short_name_limits(), + SINGLE + ) + .is_err() + ); + } + + #[test] + fn expression_names_report_the_first_placeholder_in_sorted_order() { + let n = names(&[ + ("#zz", "z".repeat(11)), + ("#b", "b".repeat(11)), + ("#ok", "x".to_owned()), + ]); + let err = + validate_expression_attribute_name_sizes(Some(&n), None, &short_name_limits(), SINGLE) + .unwrap_err(); + assert_eq!(err.to_string(), format!("{SHORT_TOO_LARGE} for key #b")); + } + + #[test] + fn expression_value_map_key_over_limit_uses_the_bare_message() { + let v = values(&[(":v", map_with_key("k".repeat(11)))]); + let err = + validate_expression_attribute_name_sizes(None, Some(&v), &short_name_limits(), SINGLE) + .unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); + } + + #[test] + fn expression_value_map_key_inside_list_and_deeper_is_checked() { + let in_list = AttributeValue::L(vec![ + AttributeValue::S("first".to_owned()), + map_with_key("k".repeat(11)), + ]); + let mut mid = Item::new(); + mid.insert("ok".to_owned(), map_with_key("k".repeat(11))); + let deep = AttributeValue::M(mid); + for value in [in_list, deep] { + let v = values(&[(":v", value)]); + let err = validate_expression_attribute_name_sizes( + None, + Some(&v), + &short_name_limits(), + SINGLE, + ) + .unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); + } + } + + #[test] + fn expression_values_without_long_map_keys_pass() { + // A long string VALUE is not a name; only map keys are measured. + let v = values(&[ + (":s", AttributeValue::S("s".repeat(100))), + (":m", map_with_key("k".repeat(10))), + ( + ":l", + AttributeValue::L(vec![AttributeValue::N("1".to_owned())]), + ), + ]); + assert!( + validate_expression_attribute_name_sizes(None, Some(&v), &short_name_limits(), SINGLE) + .is_ok() + ); + } + + #[test] + fn expression_names_are_checked_before_values() { + let n = names(&[("#n", "a".repeat(11))]); + let v = values(&[(":v", map_with_key("k".repeat(11)))]); + let err = validate_expression_attribute_name_sizes( + Some(&n), + Some(&v), + &short_name_limits(), + SINGLE, + ) + .unwrap_err(); + assert_eq!(err.to_string(), format!("{SHORT_TOO_LARGE} for key #n")); + } + + #[test] + fn expression_maps_absent_pass() { + assert!( + validate_expression_attribute_name_sizes(None, None, &short_name_limits(), SINGLE) + .is_ok() + ); + } + + fn attribute_update( + action: &str, + value: Option, + ) -> crate::types::AttributeValueUpdate { + crate::types::AttributeValueUpdate { + value, + action: action.to_owned(), + } + } + + #[test] + fn attribute_updates_name_over_limit_rejected() { + let mut updates = std::collections::HashMap::new(); + updates.insert( + "a".repeat(11), + attribute_update("PUT", Some(AttributeValue::S("x".to_owned()))), + ); + let err = + validate_attribute_updates_name_sizes(&updates, &short_name_limits()).unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); + + let mut updates = std::collections::HashMap::new(); + updates.insert("a".repeat(11), attribute_update("DELETE", None)); + assert!(validate_attribute_updates_name_sizes(&updates, &short_name_limits()).is_err()); + } + + #[test] + fn attribute_updates_map_key_inside_value_rejected() { + let mut updates = std::collections::HashMap::new(); + updates.insert( + "doc".to_owned(), + attribute_update("PUT", Some(map_with_key("k".repeat(11)))), + ); + let err = + validate_attribute_updates_name_sizes(&updates, &short_name_limits()).unwrap_err(); + assert_eq!(err.to_string(), SHORT_TOO_LARGE); + } + + #[test] + fn attribute_updates_within_limit_pass() { + let mut updates = std::collections::HashMap::new(); + updates.insert( + "a".repeat(10), + attribute_update("PUT", Some(map_with_key("k".repeat(10)))), + ); + updates.insert("b".to_owned(), attribute_update("DELETE", None)); + assert!(validate_attribute_updates_name_sizes(&updates, &short_name_limits()).is_ok()); } #[test] diff --git a/crates/engine/src/batch_write_item.rs b/crates/engine/src/batch_write_item.rs index e285a921a..ad2b9e0ab 100755 --- a/crates/engine/src/batch_write_item.rs +++ b/crates/engine/src/batch_write_item.rs @@ -14,8 +14,8 @@ use extenddb_core::types::{ WriteRequest, extract_key, item_size_bytes, }; use extenddb_core::validation::{ - validate_attribute_name_sizes, validate_batch_item_keys, validate_batch_key_only, - validate_item_nesting_depth, validate_item_size, validate_key_sizes, + AttributeNameRequestForm, validate_attribute_name_sizes, validate_batch_item_keys, + validate_batch_key_only, validate_item_nesting_depth, validate_item_size, validate_key_sizes, }; use crate::OperationContext; @@ -98,6 +98,20 @@ pub async fn handle_batch_write_item( } } + // Attribute names over the size limit are request-shape validation: every + // PutRequest item is checked before any table lookup and before the key + // checks, so a request naming a missing table, or an item missing its key, + // answers with the name refusal. + for reqs in input.request_items.values() { + for put in reqs.iter().filter_map(|wr| wr.put_request.as_ref()) { + validate_attribute_name_sizes( + &put.item, + &ctx.limits, + AttributeNameRequestForm::BatchRequest, + )?; + } + } + let empty_maps = ExpressionMaps::default(); let mut all_icm: HashMap> = HashMap::new(); @@ -132,7 +146,6 @@ pub async fn handle_batch_write_item( )?; validate_item_nesting_depth(&put.item)?; validate_item_size(&put.item, ctx.limits.max_item_size_bytes)?; - validate_attribute_name_sizes(&put.item, &ctx.limits)?; validate_key_sizes(&put.item, &key_info.key_schema, &ctx.limits)?; extenddb_core::validation::validate_vector_write( &put.item, diff --git a/crates/engine/src/delete_item.rs b/crates/engine/src/delete_item.rs index 049c368f6..7a9e712fb 100755 --- a/crates/engine/src/delete_item.rs +++ b/crates/engine/src/delete_item.rs @@ -52,6 +52,16 @@ pub async fn handle_delete_item( extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?; + // An attribute name over the size limit that arrives through the + // condition's ExpressionAttributeNames is input validation, reported + // ahead of the table lookup and the condition. + extenddb_core::validation::validate_expression_attribute_name_sizes( + input.expression_attribute_names.as_ref(), + input.expression_attribute_values.as_ref(), + &ctx.limits, + extenddb_core::validation::AttributeNameRequestForm::SingleItem, + )?; + // Validate expressions/`Expected` before the existence check, // so a missing table returns ValidationException, not ResourceNotFoundException. // Key validation stays after existence (it needs the table's key schema). diff --git a/crates/engine/src/import_export.rs b/crates/engine/src/import_export.rs index d79940420..904b87f6a 100755 --- a/crates/engine/src/import_export.rs +++ b/crates/engine/src/import_export.rs @@ -139,7 +139,11 @@ pub async fn handle_import_table( error_count += 1; continue; } - if let Err(e) = validate_attribute_name_sizes(&item, &ctx.limits) { + if let Err(e) = validate_attribute_name_sizes( + &item, + &ctx.limits, + extenddb_core::validation::AttributeNameRequestForm::SingleItem, + ) { tracing::warn!(error = %e, "import: skipping item with oversized attribute name"); error_count += 1; continue; diff --git a/crates/engine/src/put_item.rs b/crates/engine/src/put_item.rs index ffd6f70b4..15efcc9b3 100755 --- a/crates/engine/src/put_item.rs +++ b/crates/engine/src/put_item.rs @@ -94,6 +94,22 @@ pub async fn handle_put_item( } })?; + // An attribute name over the size limit is input validation: the service + // reports it ahead of the table lookup, so a request naming a missing + // table answers with the name refusal. A name that arrives only through + // the condition's ExpressionAttributeNames is refused the same way. + extenddb_core::validation::validate_attribute_name_sizes( + &input.item, + &ctx.limits, + extenddb_core::validation::AttributeNameRequestForm::SingleItem, + )?; + extenddb_core::validation::validate_expression_attribute_name_sizes( + input.expression_attribute_names.as_ref(), + input.expression_attribute_values.as_ref(), + &ctx.limits, + extenddb_core::validation::AttributeNameRequestForm::SingleItem, + )?; + // Reject mixing legacy and expression parameters, then EAN/EAV supplied // without a referencing expression. let has_expression = input diff --git a/crates/engine/src/transact_write_items.rs b/crates/engine/src/transact_write_items.rs index cc4a8951b..ca1439357 100755 --- a/crates/engine/src/transact_write_items.rs +++ b/crates/engine/src/transact_write_items.rs @@ -19,11 +19,13 @@ use crate::transact_write_helpers::{ use crate::{DispatchMetrics, DispatchResult}; use extenddb_core::error::DynamoDbError; use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; +use extenddb_core::limits::LimitsConfig; use extenddb_core::types::{ CancellationReason, Item, TransactWriteItem, TransactWriteItemsInput, TransactWriteItemsOutput, }; use extenddb_core::validation::{ - validate_attribute_name_sizes, validate_attribute_values_nesting_depth, + AttributeNameRequestForm, validate_attribute_name_sizes, + validate_attribute_values_nesting_depth, validate_expression_attribute_name_sizes, validate_item_nesting_depth, validate_item_size, validate_key_not_empty, }; @@ -91,6 +93,14 @@ pub async fn handle_transact_write_items( } } + // Attribute names over the size limit are request-shape validation of + // each member, run for every member in request order before any member's + // table lookup: a request whose first member names a missing table + // answers with a later member's name refusal. + for twi in &input.transact_items { + validate_member_attribute_name_sizes(twi, &ctx.limits)?; + } + // Resolve table key info, validate inputs, parse expressions, and check // for duplicate targets — all in a single pass to avoid redundant // `table_key_info` lookups. @@ -357,6 +367,52 @@ fn assigned_vector_attributes(actions: &[UpdateAction], maps: &ExpressionMaps) - assigned } +/// Check the attribute names one `TransactWriteItems` member carries: the +/// member's expression maps (any member type), then a `Put` member's item +/// names and map keys. An over-limit name fails the whole request with a +/// top-level ValidationException and no cancellation reasons. +/// +/// Order, measured on the service (2026-10-21) with over-limit names in two +/// places at once: a `Put` with a long `#n` and a long key in `:c` reports +/// `:c` (values before names, as the `Update` member does), and a `Put` with a +/// long item name and a long `#n` reports `#n` (expression maps before the +/// item). A standalone `PutItem` with a long item name and a long `#n` +/// reports the item, so that handler keeps the opposite order. +fn validate_member_attribute_name_sizes( + twi: &TransactWriteItem, + limits: &LimitsConfig, +) -> Result<(), DynamoDbError> { + let form = AttributeNameRequestForm::TransactionMember; + let (names, values) = if let Some(put) = &twi.put { + ( + put.expression_attribute_names.as_ref(), + put.expression_attribute_values.as_ref(), + ) + } else if let Some(del) = &twi.delete { + ( + del.expression_attribute_names.as_ref(), + del.expression_attribute_values.as_ref(), + ) + } else if let Some(upd) = &twi.update { + ( + upd.expression_attribute_names.as_ref(), + upd.expression_attribute_values.as_ref(), + ) + } else if let Some(cc) = &twi.condition_check { + ( + cc.expression_attribute_names.as_ref(), + cc.expression_attribute_values.as_ref(), + ) + } else { + return Ok(()); + }; + validate_expression_attribute_name_sizes(names, values, limits, form)?; + if let Some(put) = &twi.put { + validate_attribute_name_sizes(&put.item, limits, form)?; + } + Ok(()) +} + /// Parse and validate a single `TransactWriteItem`, returning a `PreparedOp`. async fn prepare_write_op( twi: &TransactWriteItem, @@ -370,7 +426,6 @@ async fn prepare_write_op( .map_err(storage_err_to_dynamo)?; validate_item_nesting_depth(&put.item)?; validate_item_size(&put.item, ctx.limits.max_item_size_bytes)?; - validate_attribute_name_sizes(&put.item, &ctx.limits)?; validate_key_not_empty(&put.item, &key_info.key_schema)?; let maps = build_expression_maps( put.expression_attribute_names.as_ref(), diff --git a/crates/engine/src/update_item.rs b/crates/engine/src/update_item.rs index 7f74c205c..6ed592277 100755 --- a/crates/engine/src/update_item.rs +++ b/crates/engine/src/update_item.rs @@ -62,6 +62,19 @@ pub async fn handle_update_item( extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?; + // An attribute name over the size limit is input validation: it is + // reported ahead of the table lookup, the expressions, the condition and + // the index-key checks, whichever map the name arrives through. + extenddb_core::validation::validate_expression_attribute_name_sizes( + input.expression_attribute_names.as_ref(), + input.expression_attribute_values.as_ref(), + &ctx.limits, + extenddb_core::validation::AttributeNameRequestForm::SingleItem, + )?; + if let Some(updates) = &input.attribute_updates { + extenddb_core::validation::validate_attribute_updates_name_sizes(updates, &ctx.limits)?; + } + // 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). diff --git a/docs/dynamodb-limits.md b/docs/dynamodb-limits.md index b3f4d398f..27a3e6aff 100755 --- a/docs/dynamodb-limits.md +++ b/docs/dynamodb-limits.md @@ -41,7 +41,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Maximum item size | 400 KB (409,600 bytes) | Enforced | `LimitsConfig::max_item_size_bytes`, validated on PutItem and post-UpdateItem | | Partition key size | 1–2,048 bytes | Enforced | `validate_key_sizes`, `LimitsConfig::max_partition_key_size_bytes` | | Sort key size | 1–1,024 bytes | Enforced | `validate_key_sizes`, `LimitsConfig::max_sort_key_size_bytes` | -| Attribute name size | 1–64 KB (65,535 bytes) | Partial | `validate_attribute_name_sizes`, `LimitsConfig::max_attribute_name_bytes`; top-level names and nested map keys at every depth, on PutItem, BatchWriteItem.PutRequest, TransactWriteItems.Put, and ImportTable. Not enforced on UpdateItem or TransactWriteItems.Update at any level. | +| Attribute name size | 1 to 65,535 bytes (64 KB) | Enforced | `validate_attribute_name_sizes`, `validate_expression_attribute_name_sizes`, `validate_attribute_updates_name_sizes`, `LimitsConfig::max_attribute_name_bytes`; UTF-8 bytes, top-level names and map keys at every depth. PutItem, BatchWriteItem.PutRequest, TransactWriteItems.Put and ImportTable check the item. PutItem, UpdateItem, DeleteItem and every TransactWriteItems member (Put, Update, Delete, ConditionCheck) check every `ExpressionAttributeNames` value and every map key inside every `ExpressionAttributeValues` value; UpdateItem also checks the names and values of `AttributeUpdates`. PutItem and UpdateItem report the name ahead of the table lookup; UpdateItem also reports it ahead of the condition and the index-key checks. TransactWriteItems checks every member, and BatchWriteItem every PutRequest, before any table lookup, so neither a missing table (in any member) nor a PutRequest item missing its key is reported first; a transaction fails as a whole with a top-level ValidationException. With an over-limit name in both expression maps, UpdateItem reports the `ExpressionAttributeNames` placeholder and a transacted Update or Put reports the `ExpressionAttributeValues` placeholder; a transacted Put with a long item name and a long `ExpressionAttributeNames` value reports the placeholder, a standalone PutItem the item. The message depends on the request form (`AttributeNameRequestForm`): PutItem, UpdateItem and DeleteItem answer `1 validation error detected: Attribute name is too large, must be less than 65536 bytes`, followed by ` for key #n` when the name came through `ExpressionAttributeNames`; a TransactWriteItems member answers `ExpressionAttributeNames contains invalid value: Attribute name is too large, must be less than 65536 bytes for key #n`, or `ExpressionAttributeValues contains invalid value: ... for key :v` for a map key inside a value; TransactWriteItems.Put and BatchWriteItem.PutRequest answer `Attribute name is too large, must be less than 65536 bytes` for a name in the item. | | Attribute nesting depth | 32 levels | Enforced | `validate_item_nesting_depth`, applied on PutItem, UpdateItem, BatchWriteItem.PutRequest, TransactWriteItems.Put, ImportTable | | Number of attributes per item | No practical limit | Enforced | ExtendDB has no per-item attribute count limit | @@ -151,7 +151,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb |----------|----------|---------|--------------|-----| | Throughput | 5 | 0 | 1 | 2 | | Tables | 4 | 0 | 0 | 0 | -| Items | 5 | 1 | 0 | 0 | +| Items | 6 | 0 | 0 | 0 | | Secondary Indexes | 4 | 0 | 2 | 0 | | Query/Scan | 2 | 0 | 5 | 0 | | Batch Operations | 3 | 0 | 2 | 0 | @@ -161,7 +161,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Import/Export/Backup | 0 | 0 | 0 | 8 | | Global Tables | 0 | 0 | 0 | 2 | | Contributor Insights | 0 | 0 | 0 | 1 | -| **Total** | **28** | **1** | **17** | **14** | +| **Total** | **29** | **0** | **17** | **14** | ### Unenforced Limits Requiring Tracking diff --git a/tests/test_attribute_name_limit_on_update.py b/tests/test_attribute_name_limit_on_update.py new file mode 100644 index 000000000..c9a239e96 --- /dev/null +++ b/tests/test_attribute_name_limit_on_update.py @@ -0,0 +1,690 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Attribute-name size limit on PutItem, UpdateItem, DeleteItem, TransactWriteItems and BatchWriteItem. + +An attribute name is at most 65,535 bytes of UTF-8, at every depth: a top-level +name and a map key inside any value. The refusal is a ValidationException whose +wording depends on the request form. The rules asserted here: + +- PutItem refuses a 65,536-byte name, top-level or nested, with + "1 validation error detected: Attribute name is too large, must be less than + 65536 bytes", ahead of the table lookup. 65,535 bytes is accepted. +- UpdateItem refuses a 65,536-byte name supplied through + ExpressionAttributeNames with the same message plus " for key #n", naming the + placeholder, and a 65,536-byte map key inside an ExpressionAttributeValues + value with the message alone. Both are input validation: they are reported + ahead of the table lookup, a failing ConditionExpression and a + secondary-index key type mismatch, and nothing is written. With a long name + in both maps, the ExpressionAttributeNames placeholder is reported. +- PutItem and DeleteItem refuse a 65,536-byte name that arrives only through + the ConditionExpression's ExpressionAttributeNames with the same + " for key #n" message, and nothing is written or deleted. +- A transaction member refuses an over-limit name in its expression maps with + a top-level ValidationException and no CancellationReasons, on Put, Update, + Delete and ConditionCheck members alike. The message names the map and the + placeholder: "ExpressionAttributeNames contains invalid value: Attribute + name is too large, must be less than 65536 bytes for key #n", and + "ExpressionAttributeValues contains invalid value: ... for key :v" for a map + key inside a value. With a long name in both maps of an Update or Put + member, the ExpressionAttributeValues placeholder is reported; a Put member + with a long item name and a long ExpressionAttributeNames value reports the + ExpressionAttributeNames placeholder (a standalone PutItem reports the item). +- A transacted Put and a BatchWriteItem PutRequest refuse an over-limit item + name with "Attribute name is too large, must be less than 65536 bytes", + without the "1 validation error detected:" prefix. +- In a transaction and a batch, the name check covers every member before any + table lookup: a missing table, or a missing key in a batch item, does not + take precedence over the name refusal, and no member is written. +- The limit counts UTF-8 bytes, not characters. + +Run with EXTENDDB_TEST_ENDPOINT unset to assert the same against the service. +""" + +from __future__ import annotations + +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + +LIMIT = 65535 +OVER = "a" * (LIMIT + 1) +AT = "b" * LIMIT +BARE = "Attribute name is too large, must be less than 65536 bytes" +TOO_LARGE = f"1 validation error detected: {BARE}" +TXN_NAMES = f"ExpressionAttributeNames contains invalid value: {BARE} for key #n" +TXN_VALUES = f"ExpressionAttributeValues contains invalid value: {BARE} for key :v" +TXN_CONDITION_VALUES = f"ExpressionAttributeValues contains invalid value: {BARE} for key :c" + + +def _for_key(placeholder: str) -> str: + return f"{TOO_LARGE} for key {placeholder}" + + +def _missing_table() -> str: + return f"extenddb-test-missing-{uuid.uuid4().hex[:12]}" + + +@pytest.fixture(scope="module") +def table(dynamodb_client): + """A hash-key table with a string-keyed GSI, for the index-key ordering cases.""" + with scoped_table( + dynamodb_client, + attribute_definitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + {"AttributeName": "gk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "gk-index", + "KeySchema": [{"AttributeName": "gk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ) as name: + yield name + + +def _key(k: str) -> dict: + return {"pk": {"S": k}} + + +def _seed(client, table: str, k: str) -> dict: + key = _key(k) + client.put_item(TableName=table, Item={**key, "v": {"N": "1"}}) + return key + + +def _item(client, table: str, key: dict) -> dict | None: + return client.get_item(TableName=table, Key=key, ConsistentRead=True).get("Item") + + +def _assert_too_large(exc_info, message: str) -> None: + err = exc_info.value.response["Error"] + assert err["Code"] == "ValidationException" + assert err["Message"] == message + assert "CancellationReasons" not in exc_info.value.response + + +NESTED_VALUES = [ + pytest.param({"M": {OVER: {"S": "x"}}}, id="map-key"), + pytest.param({"L": [{"S": "first"}, {"M": {OVER: {"S": "x"}}}]}, id="map-key-inside-list"), + pytest.param({"M": {"ok": {"M": {OVER: {"NULL": True}}}}}, id="map-key-two-levels-down"), +] + + +class TestPutItem: + """The PutItem refusal carries the service's message.""" + + def test_top_level_name_over_limit(self, dynamodb_client, table): + key = _key("put-top") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item(TableName=table, Item={**key, OVER: {"S": "x"}}) + _assert_too_large(exc_info, TOO_LARGE) + assert _item(dynamodb_client, table, key) is None + + def test_nested_map_key_over_limit(self, dynamodb_client, table): + key = _key("put-nested") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item(TableName=table, Item={**key, "doc": {"M": {OVER: {"S": "x"}}}}) + _assert_too_large(exc_info, TOO_LARGE) + assert _item(dynamodb_client, table, key) is None + + def test_reported_ahead_of_a_missing_table(self, dynamodb_client): + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item(TableName=_missing_table(), Item={**_key("x"), OVER: {"S": "x"}}) + _assert_too_large(exc_info, TOO_LARGE) + + def test_placeholder_name_only_in_the_condition(self, dynamodb_client, table): + # Probe row P07. + key = _key("put-cond-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item( + TableName=table, + Item={**key, "v": {"N": "1"}}, + ConditionExpression="attribute_not_exists(#n)", + ExpressionAttributeNames={"#n": OVER}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key) is None + + def test_names_at_limit_are_accepted(self, dynamodb_client, table): + key = _key("put-at") + dynamodb_client.put_item( + TableName=table, Item={**key, AT: {"S": "x"}, "doc": {"M": {AT: {"S": "y"}}}} + ) + stored = _item(dynamodb_client, table, key) + assert stored[AT] == {"S": "x"} + assert stored["doc"] == {"M": {AT: {"S": "y"}}} + + +class TestUpdateItem: + """UpdateItem refuses an over-limit name from either input map.""" + + def test_placeholder_name_over_limit(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "upd-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": OVER}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_names_map_reported_before_the_values_map(self, dynamodb_client, table): + # Probe row U13: the transacted Update reports the other map first. + key = _seed(dynamodb_client, table, "upd-both-maps") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": OVER}, + ExpressionAttributeValues={":v": {"M": {"c" * (LIMIT + 1): {"S": "x"}}}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_reported_ahead_of_a_missing_table(self, dynamodb_client): + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=_missing_table(), + Key=_key("x"), + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": OVER}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + + def test_placeholder_name_over_limit_does_not_create_the_item(self, dynamodb_client, table): + key = _key("upd-name-absent") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": OVER}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key) is None + + def test_placeholder_name_over_limit_in_a_nested_path(self, dynamodb_client, table): + key = _key("upd-name-nested-path") + dynamodb_client.put_item(TableName=table, Item={**key, "doc": {"M": {}}}) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET doc.#n = :v", + ExpressionAttributeNames={"#n": OVER}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key)["doc"] == {"M": {}} + + @pytest.mark.parametrize("value", NESTED_VALUES) + def test_map_key_over_limit_inside_a_value(self, dynamodb_client, table, value): + key = _seed(dynamodb_client, table, "upd-value") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET doc = :v", + ExpressionAttributeValues={":v": value}, + ) + _assert_too_large(exc_info, TOO_LARGE) + assert "doc" not in _item(dynamodb_client, table, key) + + def test_names_at_limit_are_accepted(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "upd-at") + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v, doc = :d", + ExpressionAttributeNames={"#n": AT}, + ExpressionAttributeValues={":v": {"S": "x"}, ":d": {"M": {AT: {"S": "y"}}}}, + ) + stored = _item(dynamodb_client, table, key) + assert stored[AT] == {"S": "x"} + assert stored["doc"] == {"M": {AT: {"S": "y"}}} + + @pytest.mark.parametrize( + "names, values, update, message", + [ + pytest.param({"#n": OVER}, {":v": {"S": "x"}}, "SET #n = :v", _for_key("#n"), id="placeholder-name"), + pytest.param(None, {":v": {"M": {OVER: {"S": "x"}}}}, "SET doc = :v", TOO_LARGE, id="map-key-in-value"), + ], + ) + def test_reported_ahead_of_a_failing_condition(self, dynamodb_client, table, names, values, update, message): + key = _seed(dynamodb_client, table, "upd-cond") + request = { + "TableName": table, + "Key": key, + "UpdateExpression": update, + "ConditionExpression": "attribute_exists(nope)", + "ExpressionAttributeValues": values, + } + if names: + request["ExpressionAttributeNames"] = names + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item(**request) + _assert_too_large(exc_info, message) + + @pytest.mark.parametrize( + "names, values, update, message", + [ + pytest.param( + {"#n": OVER}, + {":v": {"S": "x"}, ":g": {"N": "1"}}, + "SET #n = :v, gk = :g", + _for_key("#n"), + id="placeholder-name", + ), + pytest.param( + None, + {":v": {"M": {OVER: {"S": "x"}}}, ":g": {"N": "1"}}, + "SET doc = :v, gk = :g", + TOO_LARGE, + id="map-key-in-value", + ), + ], + ) + def test_reported_ahead_of_an_index_key_type_mismatch(self, dynamodb_client, table, names, values, update, message): + key = _seed(dynamodb_client, table, "upd-gsi") + request = { + "TableName": table, + "Key": key, + "UpdateExpression": update, + "ExpressionAttributeValues": values, + } + if names: + request["ExpressionAttributeNames"] = names + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item(**request) + _assert_too_large(exc_info, message) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_limit_counts_utf8_bytes(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "upd-bytes") + # 32,768 two-byte characters: 65,536 bytes. + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": "\u00e9" * 32768}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + _assert_too_large(exc_info, _for_key("#n")) + # 32,767 two-byte characters and one ASCII character: 65,535 bytes. + at_limit = "\u00e9" * 32767 + "a" + dynamodb_client.update_item( + TableName=table, + Key=key, + UpdateExpression="SET #n = :v", + ExpressionAttributeNames={"#n": at_limit}, + ExpressionAttributeValues={":v": {"S": "x"}}, + ) + assert _item(dynamodb_client, table, key)[at_limit] == {"S": "x"} + + @pytest.mark.parametrize( + "updates", + [ + pytest.param({OVER: {"Action": "PUT", "Value": {"S": "x"}}}, id="attribute-name"), + pytest.param({"doc": {"Action": "PUT", "Value": {"M": {OVER: {"S": "x"}}}}}, id="map-key-in-value"), + ], + ) + def test_legacy_attribute_updates(self, dynamodb_client, table, updates): + key = _seed(dynamodb_client, table, "upd-legacy") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item(TableName=table, Key=key, AttributeUpdates=updates) + _assert_too_large(exc_info, TOO_LARGE) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + +class TestDeleteItem: + """DeleteItem refuses an over-limit name in its condition's ExpressionAttributeNames.""" + + def test_placeholder_name_only_in_the_condition(self, dynamodb_client, table): + # Probe row D01. + key = _seed(dynamodb_client, table, "del-cond-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.delete_item( + TableName=table, + Key=key, + ConditionExpression="attribute_not_exists(#n)", + ExpressionAttributeNames={"#n": OVER}, + ) + _assert_too_large(exc_info, _for_key("#n")) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + +def _txn_update(table: str, key: dict, update: str, names: dict | None, values: dict, **extra) -> dict: + member = {"TableName": table, "Key": key, "UpdateExpression": update, "ExpressionAttributeValues": values} + if names: + member["ExpressionAttributeNames"] = names + member.update(extra) + return {"Update": member} + + +class TestTransactUpdate: + """A transacted Update with an over-limit name fails the request before any member runs.""" + + def test_placeholder_name_over_limit(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "txn-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[_txn_update(table, key, "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}})] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + @pytest.mark.parametrize("value", NESTED_VALUES) + def test_map_key_over_limit_inside_a_value(self, dynamodb_client, table, value): + key = _seed(dynamodb_client, table, "txn-value") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[_txn_update(table, key, "SET doc = :v", None, {":v": value})] + ) + _assert_too_large(exc_info, TXN_VALUES) + assert "doc" not in _item(dynamodb_client, table, key) + + def test_values_map_reported_before_the_names_map(self, dynamodb_client, table): + # Probe row T18: a standalone UpdateItem reports the other map first. + key = _seed(dynamodb_client, table, "txn-both-maps") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + _txn_update( + table, + key, + "SET #n = :v", + {"#n": OVER}, + {":v": {"M": {"c" * (LIMIT + 1): {"S": "x"}}}}, + ) + ] + ) + _assert_too_large(exc_info, TXN_VALUES) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_reported_ahead_of_another_members_missing_table(self, dynamodb_client, table): + # Probe row T14: every member's names are checked before any table lookup. + key = _key("txn-missing-other") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + {"Put": {"TableName": _missing_table(), "Item": {**_key("x"), "v": {"N": "1"}}}}, + _txn_update(table, key, "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}}), + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) is None + + @pytest.mark.parametrize("update_first", [False, True], ids=["put-then-update", "update-then-put"]) + def test_no_member_is_written(self, dynamodb_client, table, update_first): + key = _seed(dynamodb_client, table, "txn-two") + other = _key(f"txn-two-other-{update_first}") + put = {"Put": {"TableName": table, "Item": {**other, "v": {"N": "1"}}}} + update = _txn_update(table, key, "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}}) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items(TransactItems=[update, put] if update_first else [put, update]) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, other) is None + + def test_reported_ahead_of_a_failing_condition(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "txn-cond") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + _txn_update( + table, + key, + "SET #n = :v", + {"#n": OVER}, + {":v": {"S": "x"}}, + ConditionExpression="attribute_exists(nope)", + ) + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + + def test_reported_ahead_of_another_members_failing_condition(self, dynamodb_client, table): + checked = _seed(dynamodb_client, table, "txn-check") + key = _key("txn-check-update") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + {"ConditionCheck": {"TableName": table, "Key": checked, "ConditionExpression": "attribute_exists(nope)"}}, + _txn_update(table, key, "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}}), + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) is None + + def test_reported_ahead_of_an_index_key_type_mismatch(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "txn-gsi") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + _txn_update( + table, + key, + "SET #n = :v, gk = :g", + {"#n": OVER}, + {":v": {"S": "x"}, ":g": {"N": "1"}}, + ) + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_reported_ahead_of_another_members_index_key_type_mismatch(self, dynamodb_client, table): + mismatched = _seed(dynamodb_client, table, "txn-gsi-other") + key = _key("txn-gsi-other-update") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + _txn_update(table, mismatched, "SET gk = :g", None, {":g": {"N": "1"}}), + _txn_update(table, key, "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}}), + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, mismatched) == {**mismatched, "v": {"N": "1"}} + assert _item(dynamodb_client, table, key) is None + + def test_reported_ahead_of_a_missing_table(self, dynamodb_client): + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + _txn_update(_missing_table(), _key("x"), "SET #n = :v", {"#n": OVER}, {":v": {"S": "x"}}) + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + + def test_name_at_limit_is_accepted(self, dynamodb_client, table): + key = _seed(dynamodb_client, table, "txn-at") + dynamodb_client.transact_write_items( + TransactItems=[_txn_update(table, key, "SET #n = :v", {"#n": AT}, {":v": {"S": "x"}})] + ) + assert _item(dynamodb_client, table, key)[AT] == {"S": "x"} + + +ITEM_NAMES = [ + pytest.param({OVER: {"S": "x"}}, id="top-level-name"), + pytest.param({"doc": {"M": {OVER: {"S": "x"}}}}, id="nested-map-key"), +] + + +class TestTransactPut: + """A transacted Put refuses an over-limit name with the sentence alone.""" + + @pytest.mark.parametrize("attributes", ITEM_NAMES) + def test_name_over_limit(self, dynamodb_client, table, attributes): + key = _key("txn-put") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[{"Put": {"TableName": table, "Item": {**key, **attributes}}}] + ) + _assert_too_large(exc_info, BARE) + assert _item(dynamodb_client, table, key) is None + + def test_reported_ahead_of_a_missing_table(self, dynamodb_client): + # Probe row T13. + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[{"Put": {"TableName": _missing_table(), "Item": {**_key("x"), OVER: {"S": "x"}}}}] + ) + _assert_too_large(exc_info, BARE) + + def test_placeholder_name_only_in_the_condition(self, dynamodb_client, table): + # Probe row T19. + key = _key("txn-put-cond-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table, + "Item": {**key, "v": {"N": "1"}}, + "ConditionExpression": "attribute_not_exists(#n)", + "ExpressionAttributeNames": {"#n": OVER}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) is None + + def test_map_key_over_limit_inside_a_condition_value(self, dynamodb_client, table): + # Probe row T22. + key = _key("txn-put-cond-value") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table, + "Item": {**key, "v": {"N": "1"}}, + "ConditionExpression": "attribute_not_exists(zz) OR zz = :c", + "ExpressionAttributeValues": {":c": {"M": {OVER: {"S": "x"}}}}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_CONDITION_VALUES) + assert _item(dynamodb_client, table, key) is None + + def test_names_map_reported_before_the_item(self, dynamodb_client, table): + # Probe row T25: a standalone PutItem reports the item first. + key = _key("txn-put-item-and-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table, + "Item": {**key, "c" * (LIMIT + 1): {"S": "x"}}, + "ConditionExpression": "attribute_not_exists(#n)", + "ExpressionAttributeNames": {"#n": OVER}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) is None + + def test_values_map_reported_before_the_names_map(self, dynamodb_client, table): + # Probe row T26. + key = _key("txn-put-both-maps") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table, + "Item": {**key, "v": {"N": "1"}}, + "ConditionExpression": "attribute_not_exists(#n) OR zz = :c", + "ExpressionAttributeNames": {"#n": OVER}, + "ExpressionAttributeValues": {":c": {"M": {OVER: {"S": "x"}}}}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_CONDITION_VALUES) + assert _item(dynamodb_client, table, key) is None + + +class TestTransactDeleteAndConditionCheck: + """Delete and ConditionCheck members refuse an over-limit name in their condition's names map.""" + + def test_delete_placeholder_name_only_in_the_condition(self, dynamodb_client, table): + # Probe row T20. + key = _seed(dynamodb_client, table, "txn-del-cond-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Delete": { + "TableName": table, + "Key": key, + "ConditionExpression": "attribute_not_exists(#n)", + "ExpressionAttributeNames": {"#n": OVER}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + assert _item(dynamodb_client, table, key) == {**key, "v": {"N": "1"}} + + def test_condition_check_placeholder_name(self, dynamodb_client, table): + # Probe row T21. + key = _seed(dynamodb_client, table, "txn-check-cond-name") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": table, + "Key": key, + "ConditionExpression": "attribute_not_exists(#n)", + "ExpressionAttributeNames": {"#n": OVER}, + } + } + ] + ) + _assert_too_large(exc_info, TXN_NAMES) + + +class TestBatchWriteItem: + """A BatchWriteItem PutRequest refuses an over-limit name with the sentence alone.""" + + @pytest.mark.parametrize("attributes", ITEM_NAMES) + def test_name_over_limit(self, dynamodb_client, table, attributes): + key = _key("batch-put") + with pytest.raises(ClientError) as exc_info: + dynamodb_client.batch_write_item(RequestItems={table: [{"PutRequest": {"Item": {**key, **attributes}}}]}) + _assert_too_large(exc_info, BARE) + assert _item(dynamodb_client, table, key) is None + + def test_reported_ahead_of_a_missing_table(self, dynamodb_client): + # Probe row B03. + with pytest.raises(ClientError) as exc_info: + dynamodb_client.batch_write_item( + RequestItems={_missing_table(): [{"PutRequest": {"Item": {**_key("x"), OVER: {"S": "x"}}}}]} + ) + _assert_too_large(exc_info, BARE) + + def test_reported_ahead_of_a_missing_key(self, dynamodb_client, table): + # Probe row B04: the item has no pk attribute. + with pytest.raises(ClientError) as exc_info: + dynamodb_client.batch_write_item( + RequestItems={table: [{"PutRequest": {"Item": {"nope": {"S": "x"}, OVER: {"S": "x"}}}}]} + ) + _assert_too_large(exc_info, BARE)