Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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 @@ -479,12 +479,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: Amazon
// DynamoDB refuses `Begins_With(sk, :v)` with `Invalid function name;
// function: Begins_With`, and any other identifier followed by `(` the
// same way.
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: Invalid function name; function: {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,27 +18,32 @@ 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};
pub use evaluator::evaluate_condition;
pub use evaluator::{compare_orderable, evaluate_condition};
pub use key_condition::{KeyCondition, SortKeyCondition, parse_key_condition};
pub use kind::ExpressionKind;
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_refs_defined,
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
130 changes: 113 additions & 17 deletions crates/core/src/expression/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,33 +273,129 @@ 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(())
}

/// Validate that every `#name` and `:value` reference in the expressions is
/// defined in the supplied maps.
///
/// The single-item APIs discover an undefined reference while evaluating the
/// expression against the item. Transactions validate every item's expressions
/// before any table is looked up, so this runs the same check on the parsed
/// expression ahead of time. Messages match the resolver's, without the
/// `Invalid <Kind>:` prefix, which the caller adds for the expression at hand.
///
/// # Errors
///
/// Returns `DynamoDbError::ValidationException` naming the first undefined
/// reference, names before values, in the order the expressions are given.
pub fn validate_refs_defined(
names: &HashMap<String, String>,
values: &HashMap<String, AttributeValue>,
exprs: &[&super::ast::Expr],
update_actions: &[&super::ast::UpdateAction],
) -> Result<(), DynamoDbError> {
let mut used_names = std::collections::HashSet::new();
let mut used_values = std::collections::HashSet::new();
for expr in exprs {
collect_expr_refs(expr, &mut used_names, &mut used_values);
}
for action in update_actions {
collect_action_refs(action, &mut used_names, &mut used_values);
}
let mut missing_names: Vec<&String> = used_names
.iter()
.filter(|n| !names.contains_key(*n))
.collect();
missing_names.sort();
if let Some(name) = missing_names.first() {
return Err(DynamoDbError::ValidationException(format!(
"An expression attribute name used in the document path is not defined; attribute name: #{name}"
)));
}
let mut missing_values: Vec<&String> = used_values
.iter()
.filter(|v| !values.contains_key(*v))
.collect();
missing_values.sort();
if let Some(value) = missing_values.first() {
return Err(DynamoDbError::ValidationException(format!(
"An expression attribute value used in expression is not defined; attribute value: :{value}"
)));
}
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 @@ -490,7 +586,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
Loading
Loading