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
63 changes: 40 additions & 23 deletions crates/core/src/expression/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,29 +53,10 @@ pub fn evaluate_condition(
let val = resolve_to_value(operand, item, maps)?;
let lo = resolve_to_value(low, item, maps)?;
let hi = resolve_to_value(high, item, maps)?;
// DynamoDB validates bounds when both are literal placeholders
if matches!(low.as_ref(), Expr::Placeholder(_))
&& matches!(high.as_ref(), Expr::Placeholder(_))
&& let (Some(l), Some(h)) = (lo.as_deref(), hi.as_deref())
{
let l_type = attribute_type_code(l);
let h_type = attribute_type_code(h);
if l_type != h_type {
return Err(DynamoDbError::ValidationException(format!(
"Invalid ConditionExpression: The BETWEEN operator requires same data type for lower and upper bounds; lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}",
format_attribute_value(l),
format_attribute_value(h)
)));
}
let lpn = placeholder_numeric(low, maps);
let hpn = placeholder_numeric(high, maps);
if compare_values(l, h, CompareOp::Gt, lpn, hpn) {
return Err(DynamoDbError::ValidationException(format!(
"Invalid ConditionExpression: The BETWEEN operator requires upper bound to be greater than or equal to lower bound; lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}",
format_attribute_value(l),
format_attribute_value(h)
)));
}
if let Some(msg) = between_bounds_fault(low, high, maps) {
return Err(DynamoDbError::ValidationException(format!(
"Invalid ConditionExpression: {msg}"
)));
}
match (&val, &lo, &hi) {
(Some(v), Some(l), Some(h)) => {
Expand Down Expand Up @@ -127,6 +108,42 @@ fn resolve_to_value<'a>(
}
}

/// The service's sentence when both BETWEEN bounds are placeholders and they
/// differ in type or the lower is above the upper, without the `Invalid
/// <Kind>: ` label. The service checks this at request time, whatever the
/// item (measured 2026-10-26).
pub(crate) fn between_bounds_fault(
low: &Expr,
high: &Expr,
maps: &ExpressionMaps,
) -> Option<String> {
let (Expr::Placeholder(l_name), Expr::Placeholder(h_name)) = (low, high) else {
return None;
};
let (Some(l), Some(h)) = (maps.values.get(l_name), maps.values.get(h_name)) else {
return None;
};
let bounds = format!(
"lower bound operand: AttributeValue: {{{}}}, upper bound operand: AttributeValue: {{{}}}",
format_attribute_value(l),
format_attribute_value(h)
);
if attribute_type_code(l) != attribute_type_code(h) {
return Some(format!(
"The BETWEEN operator requires same data type for lower and upper bounds; {bounds}"
));
}
let (lpn, hpn) = (
placeholder_numeric(low, maps),
placeholder_numeric(high, maps),
);
compare_values(l, h, CompareOp::Gt, lpn, hpn).then(|| {
format!(
"The BETWEEN operator requires upper bound to be greater than or equal to lower bound; {bounds}"
)
})
}

/// Look up a pre-parsed `BigDecimal` for a placeholder expression.
///
/// Returns `None` if the expression is not a placeholder or has no pre-parsed value.
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/expression/key_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,13 @@ fn parse_clauses(tokens: &[Token]) -> Result<Vec<RawClause>, DynamoDbError> {
// A key condition may only reference a top-level key attribute. Any document
// path (`attr.sub` or `attr[i]`) on a key is rejected, matching Amazon
// DynamoDB. Runs after the reserved-word check and before key-schema checks.
for clause in &clauses {
if let Some(msg) = super::path_length::too_many_levels(raw_clause_path(clause)) {
return Err(validation_err(&format!(
"Invalid KeyConditionExpression: {msg}"
)));
}
}
for clause in &clauses {
reject_nested_key_path(raw_clause_path(clause))?;
}
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/expression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod key_condition;
mod kind;
mod parser;
mod parser_common;
mod path_length;
mod projection;
mod reserved_words;
mod resolver;
Expand All @@ -31,6 +32,7 @@ pub use key_condition::{
};
pub use kind::ExpressionKind;
pub use parser::{parse_condition, parse_condition_with_depth_limit};
pub use path_length::MAX_DOCUMENT_PATH_ELEMENTS;
pub use projection::{Projection, parse_projection};
pub use reserved_words::validate_no_reserved_words;
pub use resolver::{
Expand Down
131 changes: 131 additions & 0 deletions crates/core/src/expression/path_length.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! Length limit of a document path in an expression.
//!
//! Amazon `DynamoDB` refuses a document path of more than 32 elements, each
//! attribute name and each list index counting as one, in every expression
//! kind: `Invalid <Kind>: The document path has too many nesting levels;
//! nesting levels: <n>`. It checks a path's `#name` references first and then
//! its length, path by path in the order the paths appear, so an undefined
//! `#name` in an earlier path or inside the long path is reported instead
//! (measured 2026-10-26, us-east-1). The semantic walk and the projection
//! resolution run the check right after each path's names; the key condition
//! parser runs it before the names are checked, ahead of its nested-attribute
//! refusal.

use super::ast::PathElement;

/// Most elements a document path may have.
pub const MAX_DOCUMENT_PATH_ELEMENTS: usize = 32;

/// The service's sentence for `path` when it is too long, without the
/// `Invalid <Kind>: ` label the caller adds.
pub(crate) fn too_many_levels(path: &[PathElement]) -> Option<String> {
(path.len() > MAX_DOCUMENT_PATH_ELEMENTS).then(|| {
format!(
"The document path has too many nesting levels; nesting levels: {}",
path.len()
)
})
}

#[cfg(test)]
mod tests {
use super::*;

fn names(n: usize) -> Vec<PathElement> {
(0..n)
.map(|_| PathElement::Attribute("k".to_owned()))
.collect()
}

#[test]
fn thirty_two_elements_pass_and_thirty_three_are_refused() {
assert_eq!(too_many_levels(&names(32)), None);
assert_eq!(
too_many_levels(&names(33)).as_deref(),
Some("The document path has too many nesting levels; nesting levels: 33")
);
}

#[test]
fn a_list_index_counts_as_an_element() {
let mut path = names(1);
path.extend((0..32).map(PathElement::Index));
assert_eq!(path.len(), 33);
assert!(too_many_levels(&path).is_some());
path.pop();
assert_eq!(too_many_levels(&path), None);
}

fn path_text(first: &str, n: usize) -> String {
format!("{first}{}", ".k".repeat(n - 1))
}

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

fn maps() -> crate::expression::ExpressionMaps {
crate::expression::ExpressionMaps::new(
std::collections::HashMap::from([("m".to_owned(), "m".to_owned())]),
std::collections::HashMap::from([(
"v".to_owned(),
crate::types::AttributeValue::S("v".to_owned()),
)]),
)
}

const LEVELS_33: &str = "The document path has too many nesting levels; nesting levels: 33";

#[test]
fn a_key_condition_path_is_checked_before_the_nested_refusal() {
let parse = |n: usize| {
let expr = format!("pk = :v AND {} = :v", path_text("m", n));
crate::expression::parse_key_condition(&crate::expression::tokenize(&expr).unwrap())
.map(|_| ())
};
assert_eq!(
message(parse(33)),
format!("Invalid KeyConditionExpression: {LEVELS_33}")
);
assert!(message(parse(32)).contains("cannot have conditions on nested attributes"));
}

#[test]
fn a_projection_path_is_checked_after_its_names() {
let compile = |text: &str| {
let paths =
crate::expression::parse_projection(&crate::expression::tokenize(text).unwrap())
.unwrap();
crate::expression::Projection::compile(&paths, &maps(), true).map(|_| ())
};
assert_eq!(
message(compile(&path_text("#m", 33))),
format!("Invalid ProjectionExpression: {LEVELS_33}")
);
assert!(message(compile(&path_text("#zz", 33))).contains("attribute name: #zz"));
assert!(compile(&path_text("#m", 32)).is_ok());
}

#[test]
fn an_update_path_is_checked_after_its_names_in_text_order() {
let check = |text: &str| {
let tokens = crate::expression::tokenize(text).unwrap();
let parsed = crate::expression::parse_update_sections(&tokens, Some(text)).unwrap();
crate::expression::validate_update_semantics(&parsed, &maps())
};
let long = path_text("#m", 33);
assert_eq!(
message(check(&format!("SET {long} = :v"))),
format!("Invalid UpdateExpression: {LEVELS_33}")
);
assert!(message(check(&format!("SET #zz = :v, {long} = :v"))).contains("#zz"));
assert!(message(check(&format!("SET {} = :v", path_text("#zz", 33)))).contains("#zz"));
assert!(message(check(&format!("SET {long} = :v, #zz = :v"))).contains("33"));
}
}
4 changes: 4 additions & 0 deletions crates/core/src/expression/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ pub(crate) fn resolve_paths(
}
}
}
// The length after the path's names, as the service checks it.
if let Some(msg) = super::path_length::too_many_levels(path) {
return Err(DynamoDbError::ValidationException(msg));
}
resolved.push(elems);
}
Ok(resolved)
Expand Down
8 changes: 7 additions & 1 deletion crates/core/src/expression/semantics/condition_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,13 @@ fn walk_condition(
walk_condition_operand(operand, maps, kind)?;
walk_condition_operand(low, maps, kind)?;
walk_condition_operand(high, maps, kind)?;
validate_ordering_operand_types(expr, maps).map_err(|e| with_kind(e, kind))
validate_ordering_operand_types(expr, maps).map_err(|e| with_kind(e, kind))?;
// The bounds are checked at request time, so a condition that
// fails them is refused before the other expressions are checked.
match super::super::evaluator::between_bounds_fault(low, high, maps) {
Some(msg) => Err(validation(kind, &msg)),
None => Ok(()),
}
}
Expr::In { operand, list } => {
walk_condition_operand(operand, maps, kind)?;
Expand Down
10 changes: 8 additions & 2 deletions crates/core/src/expression/semantics/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

//! The name and value usage checks shared by both passes: every `#name`
//! element of a document path, and every `:value` placeholder, is defined.
//! element of a document path, and every `:value` placeholder, is defined,
//! and a document path has at most 32 elements.

use super::super::ast::PathElement;
use super::super::kind::ExpressionKind;
Expand All @@ -28,7 +29,12 @@ pub(super) fn check_path_refs(
));
}
}
Ok(())
// The service checks the length after the path's names (measured
// 2026-10-26).
match super::super::path_length::too_many_levels(path) {
Some(msg) => Err(validation(kind, &msg)),
None => Ok(()),
}
}

pub(super) fn check_value_defined(
Expand Down
22 changes: 22 additions & 0 deletions crates/core/src/expression/semantics/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,25 @@ fn key_condition_function_names() {
}
assert_eq!(key_condition_err("pk = :p AND begins_with(sk, :v)"), "OK");
}

/// The BETWEEN bounds are checked at request time (measured 2026-10-26,
/// HBTW-1: refused on a missing key and after a false AND).
#[test]
fn between_bounds_are_checked_in_the_walk() {
let m = maps(
&[],
&[
(":hi", AttributeValue::N("5".to_owned())),
(":lo", AttributeValue::N("1".to_owned())),
],
);
assert_eq!(
condition_err("a BETWEEN :hi AND :lo", &m),
format!(
"{C}The BETWEEN operator requires upper bound to be greater than or equal to lower \
bound; lower bound operand: AttributeValue: {{N:5}}, upper bound operand: \
AttributeValue: {{N:1}}"
)
);
assert!(condition_err("attribute_exists(b) AND a BETWEEN :hi AND :lo", &m).contains("BETWEEN"));
}
26 changes: 15 additions & 11 deletions crates/engine/src/put_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ pub async fn handle_put_item(

extenddb_core::validation::validate_table_name(&input.table_name, &ctx.limits)?;

// The nesting-depth limit applies to every ExpressionAttributeValue on the
// single-item write paths, condition-only placeholders included: measured
// 2026-08-24 (us-east-1), a 32-level value referenced solely by a
// ConditionExpression is rejected on PutItem, DeleteItem, and UpdateItem
// alike, while 31 levels is accepted. TransactWriteItems deliberately does
// NOT get this check: the same 32-level condition-only value is accepted
// inside a transaction on every sub-op shape (also measured). It comes
// after an invalid number in the Item and before the ConditionExpression
// and the table lookup (measured 2026-10-26), so the Item's numbers are
// checked here first; `validate_put_item` checks them again later.
if let Some(values) = &input.expression_attribute_values {
extenddb_core::validation::validate_item_numbers(&input.item)?;
extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
}

// Validate expressions/`Expected` before the existence check,
// so a malformed request to a missing table returns ValidationException,
// not ResourceNotFoundException. Key/item-content checks stay after.
Expand Down Expand Up @@ -177,17 +192,6 @@ pub async fn handle_put_item(
&key_info.attribute_definitions,
)?;

// The nesting-depth limit applies to every ExpressionAttributeValue on the
// single-item write paths, condition-only placeholders included: measured
// 2026-08-24 (us-east-1), a 32-level value referenced solely by a
// ConditionExpression is rejected on PutItem, DeleteItem, and UpdateItem
// alike, while 31 levels is accepted. TransactWriteItems deliberately does
// NOT get this check: the same 32-level condition-only value is accepted
// inside a transaction on every sub-op shape (also measured).
if let Some(values) = &input.expression_attribute_values {
extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
}

extenddb_core::validation::validate_vector_write(
&input.item,
&key_info.vector_indexes,
Expand Down
28 changes: 15 additions & 13 deletions crates/engine/src/update_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ pub async fn handle_update_item(
],
)?;

// Amazon DynamoDB enforces the nesting-depth limit on EVERY
// ExpressionAttributeValue, condition-only placeholders included: a
// 32-level value referenced solely by a ConditionExpression answers the
// nesting ValidationException rather than evaluating the condition.
// An earlier measurement had condition-only values exempt, and some
// regions did behave that way; re-measured 2026-08-24, both us-east-1 and
// eu-west-2 apply the limit to the whole map, so the per-placeholder walk
// this replaced is gone. A 31-level value still passes and the condition
// evaluates normally. It comes after the check for legacy and expression
// parameters used together and before the unused names and values, every
// expression check, the table lookup and the key (measured 2026-10-26).
if let Some(values) = &input.expression_attribute_values {
extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
}

// Desugar legacy AttributeUpdates into UpdateExpression if present.
let (effective_update_expr, extra_expr_values, extra_expr_names) =
if let Some(attr_updates) = &input.attribute_updates {
Expand Down Expand Up @@ -209,19 +224,6 @@ pub async fn handle_update_item(
// `expression::apply_update_validated`, which every backend applies to the
// evaluated image and which no expression form can bypass.

// Amazon DynamoDB enforces the nesting-depth limit on EVERY
// ExpressionAttributeValue, condition-only placeholders included: a
// 32-level value referenced solely by a ConditionExpression answers the
// nesting ValidationException rather than evaluating the condition.
// An earlier measurement had condition-only values exempt, and some
// regions did behave that way; re-measured 2026-08-24, both us-east-1 and
// eu-west-2 apply the limit to the whole map, so the per-placeholder walk
// this replaced is gone. A 31-level value still passes and the condition
// evaluates normally.
if let Some(values) = &input.expression_attribute_values {
extenddb_core::validation::validate_attribute_values_nesting_depth(values.values())?;
}

// Validate that no update action targets a key attribute (REQ-DATA-003)
validate_no_key_updates(&actions, &key_info, &maps)?;

Expand Down
Loading
Loading