Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
29229c2
fix(expressions): validate requests in the service's order and with i…
LeeroyHannigan Oct 31, 2026
aef6e1b
fix(expressions): keep the legacy filter operand-type checks, clarify…
LeeroyHannigan Oct 31, 2026
475465a
fix(expressions): refuse legacy filter operand types with the service…
LeeroyHannigan Oct 31, 2026
90fdc32
fix(expressions): validate legacy conditions with the service's rules…
LeeroyHannigan Oct 31, 2026
68dcd0a
fix(engine): order transaction ConsumedCapacity by table creation time
LeeroyHannigan Oct 31, 2026
217b5f9
fix(storage-mongodb): keep milliseconds on the catalog creation time
LeeroyHannigan Oct 31, 2026
b7ba331
docs: state the transaction capacity tie rule per backend
LeeroyHannigan Oct 31, 2026
ac76c66
refactor(core): split the expression semantic pass by rule family
LeeroyHannigan Oct 31, 2026
964f3c3
refactor(core): move the update-parser tests into their own file
LeeroyHannigan Oct 31, 2026
bf75dc7
style: remove two em dashes from comments this branch added
LeeroyHannigan Oct 31, 2026
af5f681
fix(transact): report four per-item checks where the service does
LeeroyHannigan Oct 31, 2026
d28de7a
fix(query): name the first undefined key condition reference
LeeroyHannigan Oct 31, 2026
52aad21
fix(expressions): answer four checks as the service does
LeeroyHannigan Oct 31, 2026
2c6d37b
docs: correct four comments, pin the sorted unused key list
LeeroyHannigan Oct 31, 2026
d45765d
fix(transact): apply the value nesting limits as the service does
LeeroyHannigan Oct 31, 2026
cbb04f0
fix(scan): report Segment and TotalSegments mismatches after the expr…
LeeroyHannigan Oct 31, 2026
1454cc9
docs: state what the Scan comment, the helpers header and the nesting…
LeeroyHannigan Oct 31, 2026
6f12515
Merge origin/main into fix/transact-validation-order
LeeroyHannigan Nov 5, 2026
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
17 changes: 17 additions & 0 deletions crates/core/src/expression/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,23 @@ fn placeholder_numeric<'a>(
}
}

/// Order two values of the same orderable type: `S` and `B` bytewise, `N`
/// numerically. Returns `None` for any other pair (different types, or a type
/// with no order).
#[must_use]
pub fn compare_orderable(left: &AttributeValue, right: &AttributeValue) -> Option<Ordering> {
match (left, right) {
(AttributeValue::S(l), AttributeValue::S(r)) => Some(l.cmp(r)),
(AttributeValue::B(l), AttributeValue::B(r)) => Some(l.cmp(r)),
(AttributeValue::N(l), AttributeValue::N(r)) => {
let l = l.parse::<bigdecimal::BigDecimal>().ok()?;
let r = r.parse::<bigdecimal::BigDecimal>().ok()?;
Some(l.cmp(&r))
}
_ => None,
}
}

/// Compare two `AttributeValue`s using the given operator.
///
/// `DynamoDB` comparison rules:
Expand Down
11 changes: 9 additions & 2 deletions crates/core/src/expression/key_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,12 +807,19 @@ fn parse_key_clause_inner(tokens: &[Token], pos: &mut usize) -> Result<RawClause
));
}

// begins_with(path, :val)
// begins_with(path, :val). The name is matched as written. Any other
// identifier followed by `(` is refused with the text the service gives
// it, which depends on the name (see `key_condition_function_refusal`).
if let Token::Ident(name) = &tokens[*pos]
&& name.eq_ignore_ascii_case("begins_with")
&& *pos + 1 < tokens.len()
&& tokens[*pos + 1] == Token::LParen
{
if name != "begins_with" {
return Err(validation_err(&format!(
"Invalid KeyConditionExpression: {}",
super::semantics::key_condition_function_refusal(name)
)));
}
*pos += 1; // skip function name
parser_common::expect_token(tokens, pos, &Token::LParen, "(", "KeyConditionExpression")?;
let path = parse_key_operand_path(tokens, pos)?;
Expand Down
15 changes: 10 additions & 5 deletions crates/core/src/expression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ mod projection;
mod reserved_words;
mod resolver;
mod search_condition;
mod semantics;
mod tokenizer;
mod update_evaluator;
mod update_parser;

pub use ast::{ArithOp, CompareOp, Expr, PathElement, UpdateAction, removes_only};
pub use evaluator::evaluate_condition;
pub use evaluator::{compare_orderable, evaluate_condition};
pub use key_condition::{
KeyClauseOperand, KeyCondition, SortKeyCondition, key_condition_clause_operands,
parse_key_condition,
Expand All @@ -33,15 +34,19 @@ pub use parser::{parse_condition, parse_condition_with_depth_limit};
pub use projection::{Projection, parse_projection};
pub use reserved_words::validate_no_reserved_words;
pub use resolver::{
ExpressionMaps, collect_key_condition_refs, collect_value_placeholders, resolve_element_name,
resolve_name_ref, resolve_path, validate_begins_with_operands, validate_expression_param_usage,
validate_ordering_operand_types, validate_unused_attributes,
ExpressionMaps, attribute_type_code, collect_key_condition_refs, collect_value_placeholders,
resolve_element_name, resolve_name_ref, resolve_path, validate_begins_with_operands,
validate_expression_param_usage, validate_ordering_operand_types, validate_unused_attributes,
validate_update_paths_disjoint,
};
pub use search_condition::{
MAX_INLINE_FILTER_CONDITIONS, MAX_PARTITION_KEY_CONDITIONS, MAX_SEARCH_CONDITIONS,
SearchCondition, validate_conditions_against_search_schema,
validate_search_condition_expression,
};
pub use semantics::{validate_condition_semantics, validate_update_semantics};
pub use tokenizer::{Token, tokenize, tokenize_for, tokenize_with_limit};
pub use update_evaluator::apply_update_validated;
pub use update_parser::{parse_update, parse_update_from};
pub use update_parser::{
ParsedUpdate, UpdateSection, parse_update, parse_update_from, parse_update_sections,
};
63 changes: 31 additions & 32 deletions crates/core/src/expression/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,15 @@ fn parse_primary(
return Ok(expr);
}

// Try function call: ident followed by '('
if let Token::Ident(name) = &tokens[*pos] {
let fn_name_lower = name.to_ascii_lowercase();
if is_function_name(&fn_name_lower)
&& *pos + 1 < tokens.len()
&& tokens[*pos + 1] == Token::LParen
{
return parse_function_call(tokens, pos);
}
}

// Operand — then check for comparator, BETWEEN, or IN
// Operand (a path, a placeholder, or a function call), then a comparator,
// BETWEEN, or IN. A function call with nothing after it is the condition
// itself. This pass checks grammar only: whether that function may stand
// alone, whether its name exists, and how many operands it takes are
// checked afterwards by `validate_condition_semantics`, over the whole
// parsed tree. Amazon DynamoDB reports every syntax error before any
// semantic error, so `foo(pk) AND` is reported as the syntax error at the
// end, not as the unknown function name that comes first in the text.
// Deferring the semantic checks here gives the same precedence.
let left = parse_operand(tokens, pos)?;

if *pos < tokens.len() {
Expand Down Expand Up @@ -188,6 +185,10 @@ fn parse_primary(
}
}

if matches!(left, Expr::Function { .. }) {
return Ok(left);
}

// Bare operand is not a valid condition on its own
Err(validation_err(
"Invalid ConditionExpression: expected comparison operator or function call",
Expand All @@ -207,12 +208,8 @@ fn parse_operand(tokens: &[Token], pos: &mut usize) -> Result<Expr, DynamoDbErro
*pos += 1;
Ok(expr)
}
Token::Ident(name) => {
// size(path) is a function that returns a value, usable as an operand
if name.eq_ignore_ascii_case("size")
&& *pos + 1 < tokens.len()
&& tokens[*pos + 1] == Token::LParen
{
Token::Ident(_) => {
if *pos + 1 < tokens.len() && tokens[*pos + 1] == Token::LParen {
return parse_function_call(tokens, pos);
}
let elements = parser_common::parse_path(tokens, pos)?;
Expand All @@ -230,7 +227,7 @@ fn parse_operand(tokens: &[Token], pos: &mut usize) -> Result<Expr, DynamoDbErro

fn parse_function_call(tokens: &[Token], pos: &mut usize) -> Result<Expr, DynamoDbError> {
let name = match &tokens[*pos] {
Token::Ident(n) => n.to_ascii_lowercase(),
Token::Ident(n) => n.clone(),
_ => {
return Err(validation_err(
"Invalid ConditionExpression: expected function name",
Expand All @@ -241,13 +238,22 @@ fn parse_function_call(tokens: &[Token], pos: &mut usize) -> Result<Expr, Dynamo

parser_common::expect_token(tokens, pos, &Token::LParen, "(", "ConditionExpression")?;

let mut args = Vec::new();
if *pos < tokens.len() && tokens[*pos] != Token::RParen {
// The grammar requires at least one operand between the parentheses. A
// call with an empty operand list, `foo()` or `attribute_exists()`, is a
// grammar error on the service, reported at the `)` with the text below,
// before the function name or its operand count is looked at (measured
// 2026-10-14 and again 2026-10-23, where `attribute_exists()` and `foo()`
// both answered this text; `begins_with(pk)` with one operand is instead
// the semantic operand-count error from `validate_condition_semantics`).
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
return Err(validation_err(
"Invalid ConditionExpression: Syntax error; token: \")\", near: \"()\"",
));
}
let mut args = vec![parse_operand(tokens, pos)?];
while *pos < tokens.len() && tokens[*pos] == Token::Comma {
*pos += 1;
args.push(parse_operand(tokens, pos)?);
while *pos < tokens.len() && tokens[*pos] == Token::Comma {
*pos += 1;
args.push(parse_operand(tokens, pos)?);
}
}

parser_common::expect_token(tokens, pos, &Token::RParen, ")", "ConditionExpression")?;
Expand All @@ -267,13 +273,6 @@ fn try_comparator(token: &Token) -> Option<CompareOp> {
}
}

fn is_function_name(name: &str) -> bool {
matches!(
name,
"attribute_exists" | "attribute_not_exists" | "attribute_type" | "begins_with" | "contains"
)
}

fn validation_err(msg: &str) -> DynamoDbError {
DynamoDbError::ValidationException(msg.to_owned())
}
Expand Down
15 changes: 12 additions & 3 deletions crates/core/src/expression/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,13 @@ fn prefix_projection_error(err: DynamoDbError) -> DynamoDbError {

/// A path element after `#name` resolution.
#[derive(PartialEq, Eq)]
enum ResolvedElement {
pub(crate) enum ResolvedElement {
Attr(String),
Index(usize),
}

/// Resolve `#name` references in parsed paths, rejecting index-start paths.
fn resolve_paths(
pub(crate) fn resolve_paths(
paths: &[Vec<PathElement>],
maps: &ExpressionMaps,
) -> Result<Vec<Vec<ResolvedElement>>, DynamoDbError> {
Expand Down Expand Up @@ -166,11 +166,20 @@ fn resolve_paths(
/// Reject paths where one is a prefix of another (DynamoDB's overlap rule),
/// reporting the first offending pair in expression order. Siblings are fine.
fn check_overlap(resolved: &[Vec<ResolvedElement>]) -> Result<(), DynamoDbError> {
check_overlap_for(resolved, super::ExpressionKind::Projection)
}

/// The overlap rule for any expression kind that names document paths; the
/// message carries the kind's `Invalid <Kind>:` prefix.
pub(crate) fn check_overlap_for(
resolved: &[Vec<ResolvedElement>],
kind: super::ExpressionKind,
) -> Result<(), DynamoDbError> {
for i in 0..resolved.len() {
for j in (i + 1)..resolved.len() {
if is_prefix(&resolved[i], &resolved[j]) || is_prefix(&resolved[j], &resolved[i]) {
return Err(DynamoDbError::ValidationException(format!(
"Invalid ProjectionExpression: Two document paths overlap with each other; \
"Invalid {kind}: Two document paths overlap with each other; \
must remove or rewrite one of these paths; path one: {}, path two: {}",
render_path(&resolved[i]),
render_path(&resolved[j]),
Expand Down
114 changes: 97 additions & 17 deletions crates/core/src/expression/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,33 +278,79 @@ pub fn validate_unused_attributes(
collect_action_refs(action, &mut used_names, &mut used_values);
}

for key in names.keys() {
if !used_names.contains(key.strip_prefix('#').unwrap_or(key)) {
let display_key = if key.starts_with('#') {
// The service lists every unused key in one set (`keys: {:x, :y, :z}`).
// Its order is a hash artifact no client can depend on; the keys are
// sorted here so the message is the same on every backend and process.
let mut unused_names: Vec<String> = names
.keys()
.filter(|key| !used_names.contains(key.strip_prefix('#').unwrap_or(key)))
.map(|key| {
if key.starts_with('#') {
key.clone()
} else {
format!("#{key}")
};
return Err(DynamoDbError::ValidationException(format!(
"Value provided in ExpressionAttributeNames unused in expressions: keys: {{{display_key}}}"
)));
}
}
})
.collect();
if !unused_names.is_empty() {
unused_names.sort();
return Err(DynamoDbError::ValidationException(format!(
"Value provided in ExpressionAttributeNames unused in expressions: keys: {{{}}}",
unused_names.join(", ")
)));
}
for key in values.keys() {
if !used_values.contains(key.strip_prefix(':').unwrap_or(key)) {
let display_key = if key.starts_with(':') {
let mut unused_values: Vec<String> = values
.keys()
.filter(|key| !used_values.contains(key.strip_prefix(':').unwrap_or(key)))
.map(|key| {
if key.starts_with(':') {
key.clone()
} else {
format!(":{key}")
};
return Err(DynamoDbError::ValidationException(format!(
"Value provided in ExpressionAttributeValues unused in expressions: keys: {{{display_key}}}"
)));
}
}
})
.collect();
if !unused_values.is_empty() {
unused_values.sort();
return Err(DynamoDbError::ValidationException(format!(
"Value provided in ExpressionAttributeValues unused in expressions: keys: {{{}}}",
unused_values.join(", ")
)));
}
Ok(())
}

/// Reject an update expression whose actions name overlapping document paths.
///
/// Amazon DynamoDB refuses two actions whose paths are equal or where one is a
/// prefix of the other, across all four sections, after `#name` resolution:
/// `SET a = :v, a = :w`, `SET m = :v, m.x = :w`, `SET a.b = :v REMOVE a`, and
/// `SET #l[1] = :v, #l[1].x = :w` with `#l` bound to `lst` all fail. Siblings
/// (`m.x` and `m.y`) are fine. The first offending pair in expression order is
/// named, rendered as the service renders it: `[lst, [1]]`.
///
/// # Errors
///
/// Returns `DynamoDbError::ValidationException` with the `Invalid
/// UpdateExpression:` prefix.
pub fn validate_update_paths_disjoint(
actions: &[super::ast::UpdateAction],
maps: &ExpressionMaps,
) -> Result<(), DynamoDbError> {
use super::ast::UpdateAction;
let paths: Vec<Vec<PathElement>> = actions
.iter()
.map(|action| match action {
UpdateAction::Set { path, .. }
| UpdateAction::Remove { path }
| UpdateAction::Add { path, .. }
| UpdateAction::Delete { path, .. } => path.clone(),
})
.collect();
let resolved = super::projection::resolve_paths(&paths, maps)?;
super::projection::check_overlap_for(&resolved, super::ExpressionKind::Update)
}

fn collect_expr_refs(
expr: &super::ast::Expr,
names: &mut std::collections::HashSet<String>,
Expand Down Expand Up @@ -495,7 +541,7 @@ pub fn validate_begins_with_operands(

/// Return the DynamoDB type code for an AttributeValue (S, N, B, BOOL, NULL,
/// L, M, SS, NS, BS). Used in operand-type validation messages.
pub(crate) fn attribute_type_code(val: &AttributeValue) -> &'static str {
pub fn attribute_type_code(val: &AttributeValue) -> &'static str {
match val {
AttributeValue::S(_) => "S",
AttributeValue::N(_) => "N",
Expand Down Expand Up @@ -853,4 +899,38 @@ mod tests {
collect_value_placeholders(&expr, &mut out);
assert!(out.is_empty());
}

// The service lists every unused key in one set, in an order that is a hash
// artifact. ExtendDB sorts the keys so the message is the same on every
// backend and process; this is ExtendDB's own choice, not a measurement.
// Each round builds fresh maps, which iterate in a fresh random order, so a
// missing sort cannot pass by luck.
#[test]
fn unused_keys_are_listed_sorted() {
let none = std::collections::HashSet::new();
let unused = |names: &HashMap<String, String>, values: &HashMap<String, AttributeValue>| {
match validate_unused_attributes(names, values, &[], &[], &none, &none) {
Err(DynamoDbError::ValidationException(msg)) => msg,
other => panic!("unexpected {other:?}"),
}
};
for _ in 0..20 {
let names: HashMap<String, String> = ["#z", "#a", "#m"]
.iter()
.map(|k| ((*k).to_owned(), "x".to_owned()))
.collect();
let values: HashMap<String, AttributeValue> = [":z", ":a", ":m"]
.iter()
.map(|k| ((*k).to_owned(), AttributeValue::N("1".to_owned())))
.collect();
assert_eq!(
unused(&HashMap::new(), &values),
"Value provided in ExpressionAttributeValues unused in expressions: keys: {:a, :m, :z}"
);
assert_eq!(
unused(&names, &HashMap::new()),
"Value provided in ExpressionAttributeNames unused in expressions: keys: {#a, #m, #z}"
);
}
}
}
Loading
Loading