Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/core/src/expression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod resolver;
mod search_condition;
mod tokenizer;
mod update_evaluator;
mod update_nesting;
mod update_parser;

pub use ast::{ArithOp, CompareOp, Expr, PathElement, UpdateAction, removes_only};
Expand All @@ -43,5 +44,5 @@ pub use search_condition::{
validate_search_condition_expression,
};
pub use tokenizer::{Token, tokenize, tokenize_for, tokenize_with_limit};
pub use update_evaluator::apply_update_validated;
pub use update_evaluator::{apply_update_item_validated, apply_update_validated};
pub use update_parser::{parse_update, parse_update_from};
74 changes: 74 additions & 0 deletions crates/core/src/expression/update_evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,80 @@ pub(crate) fn apply_update(
/// not reject an unrelated update to an item carrying a pre-existing invalid
/// value (measured 2026-08-14), and whole-image validation would make items
/// the backfill deliberately skipped permanently un-updatable.
///
/// Returns `ValidationException` `Nesting Levels have exceeded supported
/// limits` when an attribute that a `SET` or an `ADD` writes nests past the
/// limit, before the vector check.
pub fn apply_update_validated(
actions: &[UpdateAction],
item: &mut BTreeMap<String, AttributeValue>,
maps: &ExpressionMaps,
vector_indexes: &[VectorIndexKeyInfo],
attribute_definitions: &[AttributeDefinition],
) -> Result<(), DynamoDbError> {
apply_update_checked(
actions,
item,
maps,
vector_indexes,
attribute_definitions,
None,
)
}

/// Apply the actions of a standalone `UpdateItem`, validate the result as
/// [`apply_update_validated`] does, and size the update as
/// [`crate::validation::validate_update_item_size`] does.
///
/// The service sizes the statement before it checks the nesting depth of the
/// updated item, and sizes the updated item after that check (measured
/// us-east-1, 2026-11-10). An update whose statement is over the limit is
/// refused for its size, whatever the depth. An update whose statement is
/// within the limit and whose result nests too deep gets the depth refusal,
/// even when the updated item is over the limit. Only these orders are
/// measured, so the statement is sized ahead of the depth check only when that
/// check refuses. The order of the depth check against the vector check is not
/// measured, and the vector check keeps its place after the depth check and
/// ahead of the size check. A PartiQL `UPDATE` (`maps.finished_item_sizing`)
/// is sized by its updated item alone. The figure of the legacy
/// `AttributeUpdates` form (`maps.legacy_attribute_updates`) is computed from
/// the finished values of the attributes it names. The statement check does
/// not apply to either form, so their checks run in the order of
/// [`apply_update_validated`].
///
/// # Errors
///
/// The errors of [`apply_update_validated`], and `ValidationException` `Item
/// size to update has exceeded the maximum allowed size` when the update is
/// over `max_item_size_bytes`.
pub fn apply_update_item_validated(
actions: &[UpdateAction],
item: &mut BTreeMap<String, AttributeValue>,
maps: &ExpressionMaps,
vector_indexes: &[VectorIndexKeyInfo],
attribute_definitions: &[AttributeDefinition],
max_item_size_bytes: usize,
) -> Result<(), DynamoDbError> {
apply_update_checked(
actions,
item,
maps,
vector_indexes,
attribute_definitions,
Some(max_item_size_bytes),
)?;
crate::validation::validate_update_item_size(item, actions, maps, max_item_size_bytes)
}

/// [`apply_update_validated`]. With `statement_limit`, a depth refusal gives
/// way to the size refusal of a statement over that limit.
fn apply_update_checked(
actions: &[UpdateAction],
item: &mut BTreeMap<String, AttributeValue>,
maps: &ExpressionMaps,
vector_indexes: &[VectorIndexKeyInfo],
attribute_definitions: &[AttributeDefinition],
statement_limit: Option<usize>,
) -> Result<(), DynamoDbError> {
// Snapshot only the vector-relevant attributes (the vector attribute and
// the search-schema attributes of each index) rather than cloning the
Expand All @@ -113,6 +181,12 @@ pub fn apply_update_validated(
}
}
apply_update(actions, item, maps)?;
if let Err(depth) = super::update_nesting::validate_written_nesting_depth(actions, item, maps) {
if let Some(max_bytes) = statement_limit {
crate::validation::validate_update_statement_size(actions, maps, max_bytes)?;
}
return Err(depth);
}
crate::validation::validate_vector_write_changed(
item,
&before,
Expand Down
138 changes: 138 additions & 0 deletions crates/core/src/expression/update_evaluator_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,141 @@ fn set_parenthesised_arithmetic() {
apply("SET c = (c - :v)", &mut item, HashMap::new(), values).unwrap();
assert_eq!(item.get("c"), Some(&AttributeValue::N("7".into())));
}

/// The size checks of a standalone `UpdateItem` on either side of the depth
/// check (measured us-east-1, 2026-11-10).
mod update_item_sizing {
use super::*;
use crate::validation::ITEM_SIZE_TO_UPDATE_EXCEEDED;

const LIMIT: usize = 409_600;
const DEPTH: &str = "Nesting Levels have exceeded supported limits";

fn nmap(wrappers: usize) -> AttributeValue {
let mut value = AttributeValue::S("leaf".to_owned());
for _ in 0..wrappers {
value = AttributeValue::M(BTreeMap::from([("k".to_owned(), value)]));
}
value
}

fn text(bytes: usize) -> AttributeValue {
AttributeValue::S("a".repeat(bytes))
}

/// An item whose `m` holds 30 wrappers, beside a string of `stored` bytes.
fn item(stored: usize) -> BTreeMap<String, AttributeValue> {
BTreeMap::from([
("pk".to_owned(), AttributeValue::S("k1".to_owned())),
("m".to_owned(), nmap(30)),
("stored".to_owned(), text(stored)),
])
}

/// `SET #added = :added` of `added` bytes and a SET of 2 wrappers at `depth`
/// keys below `m`, in the order `depth_first` gives.
fn update(
added: usize,
depth: usize,
depth_first: bool,
) -> (Vec<UpdateAction>, ExpressionMaps) {
let deep = format!("#m{} = :v", ".k".repeat(depth));
let sets = if depth_first {
format!("SET {deep}, #added = :added")
} else {
format!("SET #added = :added, {deep}")
};
let maps = ExpressionMaps::new(
HashMap::from([
("m".to_owned(), "m".to_owned()),
("added".to_owned(), "added".to_owned()),
]),
HashMap::from([("v".to_owned(), nmap(2)), ("added".to_owned(), text(added))]),
);
(parse_update(&tokenize(&sets).unwrap()).unwrap(), maps)
}

fn message(result: Result<(), DynamoDbError>) -> String {
match result {
Err(DynamoDbError::ValidationException(message)) => message,
other => panic!("expected a ValidationException, got {other:?}"),
}
}

/// The answer to the update on `item` as a standalone `UpdateItem`.
fn update_item(
mut item: BTreeMap<String, AttributeValue>,
added: usize,
depth: usize,
depth_first: bool,
) -> String {
let (actions, maps) = update(added, depth, depth_first);
message(apply_update_item_validated(
&actions,
&mut item,
&maps,
&[],
&[],
LIMIT,
))
}

#[test]
fn a_statement_over_the_limit_is_refused_for_its_size_ahead_of_the_depth() {
for depth_first in [false, true] {
assert_eq!(
update_item(item(0), 409_500, 30, depth_first),
ITEM_SIZE_TO_UPDATE_EXCEEDED
);
}
}

#[test]
fn an_updated_item_over_the_limit_meets_the_depth_check_first() {
for depth_first in [false, true] {
assert_eq!(update_item(item(405_000), 8_000, 30, depth_first), DEPTH);
}
assert_eq!(update_item(item(300_000), 120_000, 30, false), DEPTH);
}

#[test]
fn the_same_update_through_a_shallow_path_is_refused_for_its_size() {
assert_eq!(
update_item(item(405_000), 8_000, 28, false),
ITEM_SIZE_TO_UPDATE_EXCEEDED
);
}

#[test]
fn a_partiql_or_legacy_update_keeps_the_depth_refusal() {
// A PartiQL UPDATE is sized by its updated item alone, and the figure
// of the legacy AttributeUpdates form is computed from the finished
// values of the attributes it names, so the statement check does not
// apply to either. The expression's statement figure is over the
// limit, and the answer is the one apply_update_validated gives.
for partiql in [true, false] {
let (actions, mut maps) = update(409_500, 30, false);
maps.finished_item_sizing = partiql;
maps.legacy_attribute_updates = !partiql;
let plain = message(apply_update_validated(
&actions,
&mut item(0),
&maps,
&[],
&[],
));
assert_eq!(plain, DEPTH);
assert_eq!(
message(apply_update_item_validated(
&actions,
&mut item(0),
&maps,
&[],
&[],
LIMIT
)),
plain
);
}
}
}
138 changes: 138 additions & 0 deletions crates/core/src/expression/update_nesting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! Nesting depth of the attributes an update expression writes.

use std::collections::BTreeMap;

use crate::error::DynamoDbError;
use crate::types::AttributeValue;
use crate::validation::nests_beyond_item_limit;

use super::ast::UpdateAction;
use super::resolver::{ExpressionMaps, resolve_element_name};

/// Refuse an updated item whose SET or ADD targets now nest beyond the limit.
///
/// The limit is the item limit of `crate::validation`: 31 M or L wrappers
/// around a scalar is 32 levels, the service's limit.
///
/// Only the top-level attributes a SET or an ADD writes are checked, each
/// once however many actions write into it. ADD is checked because it
/// creates the missing maps on its path. REMOVE and DELETE cannot deepen a
/// value, and an attribute the update does not touch is left alone, so an
/// item stored deeper than the limit before this check existed stays
/// updatable through its other attributes.
///
/// # Errors
///
/// `ValidationException` `Nesting Levels have exceeded supported limits`, the
/// service's text for UpdateItem and for a TransactWriteItems Update
/// cancellation reason (measured 2026-10-25).
pub(super) fn validate_written_nesting_depth(
actions: &[UpdateAction],
item: &BTreeMap<String, AttributeValue>,
maps: &ExpressionMaps,
) -> Result<(), DynamoDbError> {
let mut written = Vec::new();
for action in actions {
let (UpdateAction::Set { path, .. } | UpdateAction::Add { path, .. }) = action else {
continue;
};
let Some(first) = path.first() else {
continue;
};
let name = resolve_element_name(first, maps)?;
if !written.contains(&name) {
written.push(name);
}
}
for name in &written {
if item.get(name.as_ref()).is_some_and(nests_beyond_item_limit) {
return Err(DynamoDbError::ValidationException(
"Nesting Levels have exceeded supported limits".to_owned(),
));
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};

use super::*;
use crate::expression::{parse_update, tokenize};

fn nmap(wrappers: usize) -> AttributeValue {
let mut v = AttributeValue::S("leaf".to_owned());
for _ in 0..wrappers {
v = AttributeValue::M(BTreeMap::from([("k".to_owned(), v)]));
}
v
}

fn check(expr: &str, item: &BTreeMap<String, AttributeValue>) -> Result<(), DynamoDbError> {
let actions = parse_update(&tokenize(expr).unwrap()).unwrap();
// The names map is keyed without the leading `#`.
let maps = ExpressionMaps::new(
HashMap::from([("m".to_owned(), "m".to_owned())]),
HashMap::new(),
);
validate_written_nesting_depth(&actions, item, &maps)
}

fn is_nesting_error(result: Result<(), DynamoDbError>) -> bool {
matches!(
result,
Err(DynamoDbError::ValidationException(m))
if m == "Nesting Levels have exceeded supported limits"
)
}

#[test]
fn thirty_one_wrappers_pass_and_thirty_two_are_refused() {
let at = BTreeMap::from([("m".to_owned(), nmap(31))]);
assert!(check("SET #m = :v", &at).is_ok());
let over = BTreeMap::from([("m".to_owned(), nmap(32))]);
assert!(is_nesting_error(check("SET #m.k = :v", &over)));
}

#[test]
fn a_list_counts_as_a_wrapper_and_every_branch_is_checked() {
let mut deep = AttributeValue::S("leaf".to_owned());
for _ in 0..32 {
deep = AttributeValue::L(vec![AttributeValue::N("1".to_owned()), deep]);
}
let item = BTreeMap::from([("m".to_owned(), deep)]);
assert!(is_nesting_error(check("SET #m[0] = :v", &item)));
}

#[test]
fn attributes_the_update_does_not_set_are_not_checked() {
let item = BTreeMap::from([
("m".to_owned(), nmap(40)),
("x".to_owned(), AttributeValue::S("y".to_owned())),
]);
assert!(check("SET x = :v", &item).is_ok());
assert!(check("REMOVE #m.k", &item).is_ok());
assert!(is_nesting_error(check("SET #m.k = :v", &item)));
assert!(check("DELETE #m.k :v", &item).is_ok());
}

#[test]
fn an_add_target_is_checked() {
let over = BTreeMap::from([("m".to_owned(), nmap(32))]);
assert!(is_nesting_error(check("ADD #m.k :v", &over)));
let at = BTreeMap::from([("m".to_owned(), nmap(31))]);
assert!(check("ADD #m.k :v", &at).is_ok());
}

#[test]
fn two_actions_into_one_attribute_are_checked_as_one() {
let over = BTreeMap::from([("m".to_owned(), nmap(32))]);
assert!(is_nesting_error(check("SET #m.k = :v, #m.j = :v", &over)));
let at = BTreeMap::from([("m".to_owned(), nmap(31))]);
assert!(check("SET #m.k = :v, #m.j = :v ADD #m.i :v", &at).is_ok());
}
}
Loading
Loading