diff --git a/crates/core/src/expression/mod.rs b/crates/core/src/expression/mod.rs index 7fa4597..943b02e 100755 --- a/crates/core/src/expression/mod.rs +++ b/crates/core/src/expression/mod.rs @@ -27,8 +27,8 @@ pub use parser::{parse_condition, parse_condition_with_depth_limit}; pub use projection::{apply_projection, parse_projection}; pub use reserved_words::validate_no_reserved_words; pub use resolver::{ - ExpressionMaps, collect_key_condition_refs, resolve_element_name, resolve_name_ref, - resolve_path, validate_begins_with_operands, validate_unused_attributes, + ExpressionMaps, collect_key_condition_refs, collect_value_placeholders, resolve_element_name, + resolve_name_ref, resolve_path, validate_begins_with_operands, validate_unused_attributes, }; pub use tokenizer::{Token, tokenize, tokenize_for, tokenize_with_limit}; pub use update_evaluator::apply_update; diff --git a/crates/core/src/expression/resolver.rs b/crates/core/src/expression/resolver.rs index a7465fc..5ca635e 100755 --- a/crates/core/src/expression/resolver.rs +++ b/crates/core/src/expression/resolver.rs @@ -288,6 +288,46 @@ fn collect_expr_refs( } } +/// Walk an `Expr` tree and append every `Expr::Placeholder(name)` reference to `out`. +/// +/// Used by `UpdateItem` depth validation: for each `SET` action's right-hand +/// side, collect the EAV placeholders referenced (directly or via +/// `if_not_exists`, `list_append`, arithmetic, etc.). Resolving those names +/// against the `ExpressionMaps` yields the set of attribute values that will +/// be stored, so their nesting depth must be validated. +pub fn collect_value_placeholders(expr: &super::ast::Expr, out: &mut Vec) { + use super::ast::Expr; + match expr { + Expr::Placeholder(name) => out.push(name.clone()), + Expr::Path(_) => {} + Expr::Compare { left, right, .. } | Expr::Arithmetic { left, right, .. } => { + collect_value_placeholders(left, out); + collect_value_placeholders(right, out); + } + Expr::And(l, r) | Expr::Or(l, r) => { + collect_value_placeholders(l, out); + collect_value_placeholders(r, out); + } + Expr::Not(inner) => collect_value_placeholders(inner, out), + Expr::Function { args, .. } => { + for a in args { + collect_value_placeholders(a, out); + } + } + Expr::Between { operand, low, high } => { + collect_value_placeholders(operand, out); + collect_value_placeholders(low, out); + collect_value_placeholders(high, out); + } + Expr::In { operand, list } => { + collect_value_placeholders(operand, out); + for i in list { + collect_value_placeholders(i, out); + } + } + } +} + fn collect_action_refs( action: &super::ast::UpdateAction, names: &mut std::collections::HashSet, @@ -411,7 +451,7 @@ pub fn validate_begins_with_operands( #[cfg(test)] mod tests { use super::*; - use crate::expression::ast::Expr; + use crate::expression::ast::{ArithOp, Expr, PathElement}; use crate::expression::parser::parse_condition; use crate::expression::tokenizer::tokenize; use std::collections::BTreeSet; @@ -534,4 +574,72 @@ mod tests { let err = validate_begins_with_operands(&expr, &maps).unwrap_err(); assert!(err.to_string().contains("operand type: N")); } + + fn placeholder(s: &str) -> Expr { + Expr::Placeholder(s.to_owned()) + } + + #[test] + fn collect_value_placeholders_finds_direct_reference() { + let mut out = Vec::new(); + collect_value_placeholders(&placeholder(":d"), &mut out); + assert_eq!(out, vec![":d".to_owned()]); + } + + #[test] + fn collect_value_placeholders_walks_function_args() { + // SET v = if_not_exists(path, :default) + let expr = Expr::Function { + name: "if_not_exists".to_owned(), + args: vec![ + Expr::Path(vec![PathElement::Attribute("path".to_owned())]), + placeholder(":default"), + ], + }; + let mut out = Vec::new(); + collect_value_placeholders(&expr, &mut out); + assert_eq!(out, vec![":default".to_owned()]); + } + + #[test] + fn collect_value_placeholders_walks_arithmetic() { + // SET v = :a + :b + let expr = Expr::Arithmetic { + left: Box::new(placeholder(":a")), + op: ArithOp::Add, + right: Box::new(placeholder(":b")), + }; + let mut out = Vec::new(); + collect_value_placeholders(&expr, &mut out); + assert_eq!(out, vec![":a".to_owned(), ":b".to_owned()]); + } + + #[test] + fn collect_value_placeholders_walks_nested_function() { + // SET v = list_append(:base, list_append(:extra, :more)) + let expr = Expr::Function { + name: "list_append".to_owned(), + args: vec![ + placeholder(":base"), + Expr::Function { + name: "list_append".to_owned(), + args: vec![placeholder(":extra"), placeholder(":more")], + }, + ], + }; + let mut out = Vec::new(); + collect_value_placeholders(&expr, &mut out); + assert_eq!( + out, + vec![":base".to_owned(), ":extra".to_owned(), ":more".to_owned()] + ); + } + + #[test] + fn collect_value_placeholders_path_only_yields_nothing() { + let expr = Expr::Path(vec![PathElement::Attribute("address".to_owned())]); + let mut out = Vec::new(); + collect_value_placeholders(&expr, &mut out); + assert!(out.is_empty()); + } } diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index df97113..83df75c 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -459,6 +459,7 @@ pub fn validate_put_item( 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)?; let size = item_size_bytes(&input.item); if size > limits.max_item_size_bytes { @@ -535,6 +536,11 @@ pub fn validate_update_item( ) -> Result<(), DynamoDbError> { validate_table_name(&input.table_name, limits)?; validate_key_only(&input.key, key_schema, attr_defs)?; + + if let Some(updates) = &input.attribute_updates { + validate_attribute_values_nesting_depth(updates.values().filter_map(|u| u.value.as_ref()))?; + } + Ok(()) } @@ -837,6 +843,63 @@ fn validate_attribute_number(value: &AttributeValue) -> Result<(), DynamoDbError Ok(()) } +/// Maximum total nesting levels (M/L wrappers plus the leaf) DynamoDB allows. +pub(crate) const MAX_ITEM_NESTING_DEPTH: usize = 32; + +/// Validate that no attribute value in `item` nests beyond `MAX_ITEM_NESTING_DEPTH`. +pub fn validate_item_nesting_depth(item: &Item) -> Result<(), DynamoDbError> { + for value in item.values() { + check_attribute_value_depth(value, 0)?; + } + Ok(()) +} + +/// Validate nesting depth on attribute values introduced outside of an `Item` +/// (`ExpressionAttributeValues`, `AttributeUpdates`, `Expected`). +pub fn validate_attribute_values_nesting_depth<'a, I>(values: I) -> Result<(), DynamoDbError> +where + I: IntoIterator, +{ + for v in values { + check_attribute_value_depth(v, 0)?; + } + Ok(()) +} + +fn check_attribute_value_depth( + value: &AttributeValue, + current_depth: usize, +) -> Result<(), DynamoDbError> { + match value { + AttributeValue::M(map) => { + let next = current_depth + 1; + if next >= MAX_ITEM_NESTING_DEPTH { + return Err(nesting_depth_error()); + } + for v in map.values() { + check_attribute_value_depth(v, next)?; + } + } + AttributeValue::L(list) => { + let next = current_depth + 1; + if next >= MAX_ITEM_NESTING_DEPTH { + return Err(nesting_depth_error()); + } + for v in list { + check_attribute_value_depth(v, next)?; + } + } + _ => {} + } + Ok(()) +} + +fn nesting_depth_error() -> DynamoDbError { + DynamoDbError::ValidationException( + "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit".to_owned(), + ) +} + fn validate_unique_index_names(input: &CreateTableInput) -> Result<(), DynamoDbError> { let mut names = std::collections::HashSet::new(); if let Some(gsis) = &input.global_secondary_indexes { @@ -1230,4 +1293,148 @@ mod tests { input.update_expression = Some(String::new()); assert!(validate_update_item(&input, &limits, &key_schema, &attr_defs).is_ok()); } + + fn nested_map(depth: usize) -> AttributeValue { + let mut leaf = AttributeValue::S("leaf".to_owned()); + for _ in 0..depth { + let mut m = std::collections::BTreeMap::new(); + m.insert("a".to_owned(), leaf); + leaf = AttributeValue::M(m); + } + leaf + } + + fn nested_list(depth: usize) -> AttributeValue { + let mut leaf = AttributeValue::S("leaf".to_owned()); + for _ in 0..depth { + leaf = AttributeValue::L(vec![leaf]); + } + leaf + } + + #[test] + fn nesting_depth_at_limit_accepted() { + // 31 wrappers + leaf = 32 total levels, DynamoDB's hard cap. + let mut item = Item::new(); + item.insert("deep".to_owned(), nested_map(MAX_ITEM_NESTING_DEPTH - 1)); + validate_item_nesting_depth(&item).expect("32 total levels must be accepted"); + } + + #[test] + fn nesting_depth_one_over_limit_rejected_for_map() { + let mut item = Item::new(); + item.insert("deep".to_owned(), nested_map(MAX_ITEM_NESTING_DEPTH)); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_one_over_limit_rejected_for_list() { + let mut item = Item::new(); + item.insert("deep".to_owned(), nested_list(MAX_ITEM_NESTING_DEPTH)); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_mixed_map_and_list_counted_together() { + let mut leaf = AttributeValue::S("leaf".to_owned()); + for i in 0..MAX_ITEM_NESTING_DEPTH { + leaf = if i % 2 == 0 { + AttributeValue::L(vec![leaf]) + } else { + let mut m = std::collections::BTreeMap::new(); + m.insert("a".to_owned(), leaf); + AttributeValue::M(m) + }; + } + let mut item = Item::new(); + item.insert("deep".to_owned(), leaf); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_attribute_values_iterator_at_limit_accepted() { + let v = nested_map(MAX_ITEM_NESTING_DEPTH - 1); + validate_attribute_values_nesting_depth(std::iter::once(&v)) + .expect("32 total levels via iterator must be accepted"); + } + + #[test] + fn nesting_depth_attribute_values_iterator_one_over_rejected() { + let v = nested_map(MAX_ITEM_NESTING_DEPTH); + let err = validate_attribute_values_nesting_depth(std::iter::once(&v)).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_visits_all_top_level_attributes() { + // Only one of three top-level attributes is over the limit. The + // recursion must inspect every attribute and reject. + let mut item = Item::new(); + item.insert("shallow_a".to_owned(), AttributeValue::S("a".to_owned())); + item.insert("deep".to_owned(), nested_map(MAX_ITEM_NESTING_DEPTH)); + item.insert("shallow_b".to_owned(), AttributeValue::N("42".to_owned())); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_visits_all_map_children() { + // A wide Map: many children, only one is over the limit. + let mut wide = std::collections::BTreeMap::new(); + wide.insert("a".to_owned(), AttributeValue::S("x".to_owned())); + wide.insert("b".to_owned(), nested_map(MAX_ITEM_NESTING_DEPTH - 1)); + wide.insert("c".to_owned(), nested_map(MAX_ITEM_NESTING_DEPTH)); + wide.insert("d".to_owned(), AttributeValue::N("1".to_owned())); + let mut item = Item::new(); + item.insert("wide".to_owned(), AttributeValue::M(wide)); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } + + #[test] + fn nesting_depth_visits_all_list_elements() { + // A wide List: many elements, only one element is over the limit. + let wide = vec![ + AttributeValue::S("x".to_owned()), + nested_map(MAX_ITEM_NESTING_DEPTH - 1), + AttributeValue::Bool(true), + nested_map(MAX_ITEM_NESTING_DEPTH), + AttributeValue::N("3".to_owned()), + ]; + let mut item = Item::new(); + item.insert("wide".to_owned(), AttributeValue::L(wide)); + let err = validate_item_nesting_depth(&item).unwrap_err(); + assert!( + err.to_string() + .contains("Nesting Levels have exceeded supported limits"), + "unexpected error: {err}" + ); + } } diff --git a/crates/engine/src/batch_write_item.rs b/crates/engine/src/batch_write_item.rs index 5d75c31..e0bdd2d 100755 --- a/crates/engine/src/batch_write_item.rs +++ b/crates/engine/src/batch_write_item.rs @@ -15,7 +15,7 @@ use extenddb_core::types::{ }; use extenddb_core::validation::{ validate_attribute_name_sizes, validate_batch_item_keys, validate_batch_key_only, - validate_item_size, validate_key_sizes, + validate_item_nesting_depth, validate_item_size, validate_key_sizes, }; use crate::OperationContext; @@ -123,6 +123,7 @@ pub async fn handle_batch_write_item( &key_info.key_schema, &key_info.attribute_definitions, )?; + 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)?; diff --git a/crates/engine/src/import_export.rs b/crates/engine/src/import_export.rs index 73c503f..766688d 100755 --- a/crates/engine/src/import_export.rs +++ b/crates/engine/src/import_export.rs @@ -20,7 +20,8 @@ use extenddb_core::types::{ ImportTableOutput, Item, TableCreationParameters, }; use extenddb_core::validation::{ - validate_attribute_name_sizes, validate_item_keys, validate_item_size, validate_key_sizes, + validate_attribute_name_sizes, validate_item_keys, validate_item_nesting_depth, + validate_item_size, validate_key_sizes, }; /// Handle an `ImportTable` request. @@ -107,6 +108,11 @@ pub async fn handle_import_table( error_count += 1; continue; } + if let Err(e) = validate_item_nesting_depth(&item) { + tracing::warn!(error = %e, "import: skipping item with excessive nesting"); + error_count += 1; + continue; + } if let Err(e) = validate_item_size(&item, ctx.limits.max_item_size_bytes) { tracing::warn!(error = %e, "import: skipping oversized item"); error_count += 1; diff --git a/crates/engine/src/transact_write_items.rs b/crates/engine/src/transact_write_items.rs index 5c11e89..42a6d80 100755 --- a/crates/engine/src/transact_write_items.rs +++ b/crates/engine/src/transact_write_items.rs @@ -22,7 +22,8 @@ use extenddb_core::error::DynamoDbError; use extenddb_core::expression::parse_update; use extenddb_core::types::{TransactWriteItem, TransactWriteItemsInput, TransactWriteItemsOutput}; use extenddb_core::validation::{ - validate_attribute_name_sizes, validate_item_size, validate_key_sizes, + validate_attribute_name_sizes, validate_attribute_values_nesting_depth, + validate_item_nesting_depth, validate_item_size, validate_key_sizes, }; /// Maximum number of items in a single `TransactWriteItems` request. @@ -208,6 +209,7 @@ async fn prepare_write_op( .table_key_info(&ctx.account_id, &put.table_name) .await .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_sizes(&put.item, &key_info.key_schema, &ctx.limits)?; @@ -295,6 +297,21 @@ async fn prepare_write_op( crate::expression_helpers::tokenize_expression(&upd.update_expression, &ctx.limits)?; let actions = parse_update(&update_tokens)?; validate_no_key_updates(&actions, &key_info, &maps)?; + + // Validate nesting depth of EAV values that get stored via SET actions. + { + let mut placeholders: Vec = Vec::new(); + for action in &actions { + if let extenddb_core::expression::UpdateAction::Set { value, .. } = action { + extenddb_core::expression::collect_value_placeholders(value, &mut placeholders); + } + } + let stored: Vec<&extenddb_core::types::AttributeValue> = placeholders + .iter() + .filter_map(|name| maps.values.get(name)) + .collect(); + validate_attribute_values_nesting_depth(stored)?; + } let condition = parse_optional_condition(upd.condition_expression.as_deref(), &ctx.limits)?; { let exprs: Vec<&extenddb_core::expression::Expr> = condition.iter().collect(); diff --git a/crates/engine/src/update_item.rs b/crates/engine/src/update_item.rs index cd869e5..3f2b905 100755 --- a/crates/engine/src/update_item.rs +++ b/crates/engine/src/update_item.rs @@ -137,6 +137,24 @@ pub async fn handle_update_item( Vec::new() }; + // Amazon DynamoDB enforces nesting depth on values that are stored as item + // attributes. For UpdateExpression, walk each SET action's RHS to find the + // EAV placeholders it references, resolve them against `maps.values`, and + // validate those values' depth. Condition-only EAV is left alone. + { + let mut placeholders: Vec = Vec::new(); + for action in &actions { + if let UpdateAction::Set { value, .. } = action { + extenddb_core::expression::collect_value_placeholders(value, &mut placeholders); + } + } + let stored: Vec<&extenddb_core::types::AttributeValue> = placeholders + .iter() + .filter_map(|name| maps.values.get(name)) + .collect(); + extenddb_core::validation::validate_attribute_values_nesting_depth(stored)?; + } + if input.expected.is_none() || input.expected.as_ref().is_some_and(|m| m.is_empty()) { let exprs: Vec<&extenddb_core::expression::Expr> = condition.iter().collect(); extenddb_core::expression::validate_unused_attributes( diff --git a/docs/dynamodb-limits.md b/docs/dynamodb-limits.md index 5fded45..1ed38d1 100755 --- a/docs/dynamodb-limits.md +++ b/docs/dynamodb-limits.md @@ -42,7 +42,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb | 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) | Enforced | `validate_attribute_name_sizes`, `LimitsConfig::max_attribute_name_bytes` | -| Attribute nesting depth | 32 levels | Not enforced | No nesting depth validation | +| 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 | ## Secondary Indexes @@ -149,7 +149,7 @@ Source: [AWS DynamoDB Service Quotas](https://docs.aws.amazon.com/amazondynamodb |----------|----------|---------|--------------|-----| | Throughput | 5 | 0 | 1 | 2 | | Tables | 4 | 0 | 0 | 0 | -| Items | 5 | 0 | 1 | 0 | +| Items | 6 | 0 | 0 | 0 | | Secondary Indexes | 4 | 0 | 2 | 0 | | Query/Scan | 2 | 0 | 5 | 0 | | Batch Operations | 3 | 0 | 2 | 0 | @@ -159,24 +159,23 @@ 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** | **0** | **18** | **14** | +| **Total** | **29** | **0** | **17** | **14** | ### Unenforced Limits Requiring Tracking The following unenforced limits are tracked in `docs/technical-debt.md`: 1. **Provisioned capacity decrease limit** (27/day) — would require per-table decrease counter with hourly replenishment -2. **Attribute nesting depth** (32 levels) — requires recursive depth check in attribute value validation -3. **Projected attributes across all indexes** (100) — requires cross-index attribute counting in CreateTable validation -4. **LSI item collection size** (10 GB) — requires per-partition size tracking in storage layer -5. **Expression size limits** (4 KB condition/filter/projection, 2 MB names/values) — requires byte-length checks on expression strings -6. **BatchGetItem response size** (16 MB) — requires aggregate response size tracking -7. **BatchWriteItem request size** (16 MB) — requires aggregate request size tracking -8. **Transaction request size** (4 MB) — requires aggregate request size tracking -9. **GetRecords max per call** (1,000) — requires record count limit in streams -10. **Shard iterator lifetime** (15 minutes) — requires timestamp tracking on shard iterators -11. **Tag count per resource** (50) — requires count validation in TagResource -12. **Tag key/value length** (128/256 chars) — requires length validation in TagResource +2. **Projected attributes across all indexes** (100) — requires cross-index attribute counting in CreateTable validation +3. **LSI item collection size** (10 GB) — requires per-partition size tracking in storage layer +4. **Expression size limits** (4 KB condition/filter/projection, 2 MB names/values) — requires byte-length checks on expression strings +5. **BatchGetItem response size** (16 MB) — requires aggregate response size tracking +6. **BatchWriteItem request size** (16 MB) — requires aggregate request size tracking +7. **Transaction request size** (4 MB) — requires aggregate request size tracking +8. **GetRecords max per call** (1,000) — requires record count limit in streams +9. **Shard iterator lifetime** (15 minutes) — requires timestamp tracking on shard iterators +10. **Tag count per resource** (50) — requires count validation in TagResource +11. **Tag key/value length** (128/256 chars) — requires length validation in TagResource --- diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 4b81913..5b0f1af 100755 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -87,16 +87,15 @@ See `docs/dynamodb-limits.md` for the full catalog. The following are the highes | # | Limit | DynamoDB Value | Priority | Origin | |---|-------|---------------|----------|--------| -| L-1 | Attribute nesting depth | 32 levels | Medium | P42 | -| L-2 | Projected attributes across all indexes | 100 | Medium | P42 | -| L-3 | Expression size limits (condition/filter/projection) | 4 KB each | Low | P42 | -| L-4 | Batch/transaction aggregate request size | 4–16 MB | Low | P42 | -| L-5 | GetRecords max per call | 1,000 records | Low | P42 | -| L-6 | Shard iterator lifetime | 15 minutes | Medium | P42 | -| L-7 | Tag count per resource | 50 | Low | P42 | -| L-8 | Tag key/value length limits | 128/256 chars | Low | P42 | -| L-9 | LSI item collection size | 10 GB | Low | P42 | -| L-10 | Provisioned capacity decrease limit | 27/day | Low | P42 | +| L-1 | Projected attributes across all indexes | 100 | Medium | P42 | +| L-2 | Expression size limits (condition/filter/projection) | 4 KB each | Low | P42 | +| L-3 | Batch/transaction aggregate request size | 4–16 MB | Low | P42 | +| L-4 | GetRecords max per call | 1,000 records | Low | P42 | +| L-5 | Shard iterator lifetime | 15 minutes | Medium | P42 | +| L-6 | Tag count per resource | 50 | Low | P42 | +| L-7 | Tag key/value length limits | 128/256 chars | Low | P42 | +| L-8 | LSI item collection size | 10 GB | Low | P42 | +| L-9 | Provisioned capacity decrease limit | 27/day | Low | P42 | ## File Size Overages (>500 lines) diff --git a/tests/test_item_operations.py b/tests/test_item_operations.py index cff0ca4..951c6d6 100755 --- a/tests/test_item_operations.py +++ b/tests/test_item_operations.py @@ -9,6 +9,8 @@ from __future__ import annotations +import uuid + import pytest from botocore.exceptions import ClientError @@ -502,6 +504,272 @@ def test_update_item_legacy_delete_with_updated_new_omits_attributes( assert "Attributes" not in resp, f"expected Attributes omitted, got {resp.get('Attributes')!r}" +class TestNestingDepth: + """Amazon DynamoDB rejects items whose Map/List values nest beyond 32 levels. + + Each `M` or `L` wrapper counts as one level; scalar leaves do not. The + cap applies to top-level item attributes (`PutItem`, `BatchWriteItem`, + `TransactWriteItems.Put`) and to attribute values introduced through + `UpdateItem.AttributeUpdates` and `UpdateItem.ExpressionAttributeValues`. + """ + + @pytest.fixture(scope="class") + def nest_table(self, dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + @staticmethod + def _deep_map(depth: int): + leaf = {"S": "leaf"} + for _ in range(depth): + leaf = {"M": {"a": leaf}} + return leaf + + @staticmethod + def _deep_list(depth: int): + leaf = {"S": "leaf"} + for _ in range(depth): + leaf = {"L": [leaf]} + return leaf + + def test_put_item_at_limit_accepted(self, dynamodb_client, nest_table): + """PutItem with a Map nested 31 levels deep (32 total levels) is accepted.""" + dynamodb_client.put_item( + TableName=nest_table, + Item={"pk": {"S": "at-limit"}, "deep": self._deep_map(31)}, + ) + + def test_put_item_one_over_limit_map_rejected(self, dynamodb_client, nest_table): + """PutItem with a Map nested 32 levels deep (33 total levels) returns ValidationException.""" + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item( + TableName=nest_table, + Item={"pk": {"S": "over-map"}, "deep": self._deep_map(32)}, + ) + err = exc_info.value.response["Error"] + assert err["Code"] == "ValidationException" + assert "Nesting Levels have exceeded supported limits" in err["Message"] + + def test_put_item_one_over_limit_list_rejected(self, dynamodb_client, nest_table): + """PutItem with a List nested 32 levels deep returns ValidationException.""" + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item( + TableName=nest_table, + Item={"pk": {"S": "over-list"}, "deep": self._deep_list(32)}, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_update_item_attribute_updates_one_over_limit_rejected( + self, dynamodb_client, nest_table + ): + """UpdateItem AttributeUpdates PUT with 32-deep Map returns ValidationException.""" + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": "upd-au"}}) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=nest_table, + Key={"pk": {"S": "upd-au"}}, + AttributeUpdates={ + "deep": {"Action": "PUT", "Value": self._deep_map(32)} + }, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_update_item_set_deep_eav_rejected(self, dynamodb_client, nest_table): + """UpdateItem with SET path = :d where :d is 32-deep is rejected. + + The deep value goes into a stored attribute, so Amazon DynamoDB rejects. + Regression guard: prior to the engine-side walker that resolves SET + action placeholders against ExpressionAttributeValues, this case slipped + through ExtendDB's validation while Amazon DynamoDB rejected. + """ + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": "upd-set"}}) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=nest_table, + Key={"pk": {"S": "upd-set"}}, + UpdateExpression="SET deep = :d", + ExpressionAttributeValues={":d": self._deep_map(32)}, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_update_item_set_if_not_exists_deep_eav_rejected( + self, dynamodb_client, nest_table + ): + """SET path = if_not_exists(path, :d) with deep :d is rejected. + + Walker-coverage: the EAV reference is nested inside a function call. + """ + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": "upd-ine"}}) + with pytest.raises(ClientError) as exc_info: + dynamodb_client.update_item( + TableName=nest_table, + Key={"pk": {"S": "upd-ine"}}, + UpdateExpression="SET deep = if_not_exists(deep, :d)", + ExpressionAttributeValues={":d": self._deep_map(32)}, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_batch_write_item_put_one_over_limit_rejected( + self, dynamodb_client, nest_table + ): + """BatchWriteItem PutRequest with 32-deep Map returns ValidationException.""" + with pytest.raises(ClientError) as exc_info: + dynamodb_client.batch_write_item( + RequestItems={ + nest_table: [ + { + "PutRequest": { + "Item": { + "pk": {"S": "batch-over"}, + "deep": self._deep_map(32), + } + } + } + ] + } + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_transact_write_items_put_one_over_limit_rejected( + self, dynamodb_client, nest_table + ): + """TransactWriteItems Put with 32-deep Map returns ValidationException.""" + with pytest.raises(ClientError) as exc_info: + dynamodb_client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": nest_table, + "Item": { + "pk": {"S": "twi-over"}, + "deep": self._deep_map(32), + }, + } + } + ] + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + def test_put_item_deep_eav_in_condition_accepted(self, dynamodb_client, nest_table): + """PutItem with a deep value in EAV used only by ConditionExpression is accepted. + + Amazon DynamoDB only validates depth on values that get *stored* as item + attributes. Condition-only EAV passes through. + """ + unique = "cond-pk-" + uuid.uuid4().hex[:8] + dynamodb_client.put_item( + TableName=nest_table, + Item={"pk": {"S": unique}}, + ConditionExpression="attribute_not_exists(pk) OR :d = :d", + ExpressionAttributeValues={":d": self._deep_map(32)}, + ) + + def test_delete_item_deep_eav_in_condition_accepted(self, dynamodb_client, nest_table): + """DeleteItem with a deep value in EAV used only by ConditionExpression is accepted.""" + unique = "cond-del-" + uuid.uuid4().hex[:8] + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": unique}}) + dynamodb_client.delete_item( + TableName=nest_table, + Key={"pk": {"S": unique}}, + ConditionExpression="attribute_exists(pk) OR :d = :d", + ExpressionAttributeValues={":d": self._deep_map(32)}, + ) + + def test_update_item_deep_eav_in_condition_accepted(self, dynamodb_client, nest_table): + """UpdateItem with a deep value in EAV used only by ConditionExpression is accepted. + + The SET target is a shallow scalar; the deep `:d` is referenced only by + the ConditionExpression and never stored. + """ + unique = "cond-upd-" + uuid.uuid4().hex[:8] + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": unique}}) + dynamodb_client.update_item( + TableName=nest_table, + Key={"pk": {"S": unique}}, + UpdateExpression="SET myattr = :s", + ConditionExpression=":d = :d", + ExpressionAttributeValues={":s": {"S": "x"}, ":d": self._deep_map(32)}, + ) + + def test_update_item_legacy_expected_with_deep_value_accepted( + self, dynamodb_client, nest_table + ): + """UpdateItem legacy `Expected..Value` carrying a deep value is not depth-validated. + + Amazon DynamoDB lets the request through validation. The condition itself + fails at evaluation (ConditionalCheckFailedException), which is fine for + this assertion: what we are guarding against is `ValidationException` for + nesting depth, not the condition outcome. + """ + unique = "exp-upd-" + uuid.uuid4().hex[:8] + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": unique}}) + try: + dynamodb_client.update_item( + TableName=nest_table, + Key={"pk": {"S": unique}}, + AttributeUpdates={"myattr": {"Action": "PUT", "Value": {"S": "x"}}}, + Expected={"deep": {"Value": self._deep_map(32)}}, + ) + except ClientError as e: + err = e.response["Error"] + assert err["Code"] != "ValidationException", ( + f"Expected condition with deep value should not be a ValidationException: {err}" + ) + + def test_transact_write_items_condition_check_deep_eav_accepted( + self, dynamodb_client, nest_table + ): + """TransactWriteItems ConditionCheck with a deep value in EAV is accepted.""" + unique = "twi-cc-" + uuid.uuid4().hex[:8] + dynamodb_client.put_item(TableName=nest_table, Item={"pk": {"S": unique}}) + dynamodb_client.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": nest_table, + "Key": {"pk": {"S": unique}}, + "ConditionExpression": "attribute_exists(pk) OR :d = :d", + "ExpressionAttributeValues": {":d": self._deep_map(32)}, + } + } + ] + ) + + def test_put_item_31_wrappers_around_set_leaf_accepted(self, dynamodb_client, nest_table): + """31 `M` wrappers around a number-set leaf (32 total levels) is accepted. + + Set types (NS/SS/BS) count as scalar leaves: the recursion does not + descend into their members for nesting-depth purposes. + """ + leaf = {"NS": ["1", "2", "3"]} + for _ in range(31): + leaf = {"M": {"a": leaf}} + dynamodb_client.put_item( + TableName=nest_table, + Item={"pk": {"S": "set-leaf-31"}, "deep": leaf}, + ) + + def test_put_item_multiple_top_level_attributes_one_over_rejected( + self, dynamodb_client, nest_table + ): + """PutItem rejects when any single top-level attribute exceeds the limit. + + Guards that the recursion visits every top-level attribute, not just the + first one. + """ + with pytest.raises(ClientError) as exc_info: + dynamodb_client.put_item( + TableName=nest_table, + Item={ + "pk": {"S": "multi-over"}, + "shallow_a": {"S": "x"}, + "deep": self._deep_map(32), + "shallow_b": {"N": "1"}, + }, + ) + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + # --------------------------------------------------------------------------- # DeleteItem tests # ---------------------------------------------------------------------------