diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index c144b46b..eb072773 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -1085,8 +1085,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)?; @@ -1216,59 +1217,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. @@ -2603,7 +2779,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] @@ -2668,10 +2844,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" ); } @@ -2777,18 +2953,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] @@ -2803,11 +3085,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] @@ -2824,7 +3103,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] @@ -2838,7 +3117,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] @@ -2848,13 +3127,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 b0381c7a..71cd354f 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; @@ -66,6 +66,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(); @@ -99,7 +113,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 049c368f..7a9e712f 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 a6ecba4b..a9748305 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 ffd6f70b..15efcc9b 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 43fb78a1..ca953de6 100755 --- a/crates/engine/src/transact_write_items.rs +++ b/crates/engine/src/transact_write_items.rs @@ -20,11 +20,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, }; @@ -75,6 +77,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. @@ -341,6 +351,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, @@ -353,7 +409,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 7f74c205..6ed59227 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 50383695..270786c9 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 | @@ -157,7 +157,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 | 5 | 0 | 1 | 0 | | Query/Scan | 2 | 0 | 5 | 0 | | Batch Operations | 3 | 0 | 2 | 0 | @@ -167,7 +167,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | Import/Export/Backup | 2 | 2 | 0 | 7 | | Global Tables | 0 | 0 | 0 | 2 | | Contributor Insights | 0 | 0 | 0 | 1 | -| **Total** | **31** | **3** | **16** | **13** | +| **Total** | **32** | **2** | **16** | **13** | ### 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 00000000..c9a239e9 --- /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)