Skip to main content

dynoxide/expressions/
mod.rs

1//! DynamoDB expression parsing and evaluation.
2//!
3//! Supports all five expression types:
4//! - `ConditionExpression` / `FilterExpression` — conditional checks
5//! - `KeyConditionExpression` — Query partition + sort key conditions
6//! - `ProjectionExpression` — attribute subset selection
7//! - `UpdateExpression` — item mutation (SET, REMOVE, ADD, DELETE)
8
9pub mod condition;
10pub mod key_condition;
11pub mod projection;
12pub mod reserved;
13pub mod tokenizer;
14pub mod update;
15
16use crate::errors::DynoxideError;
17use crate::types::AttributeValue;
18use std::cell::RefCell;
19use std::collections::{HashMap, HashSet};
20
21/// The maximum byte length DynamoDB allows for a single expression parameter.
22pub(crate) const MAX_EXPRESSION_BYTES: usize = 4096;
23
24/// Reject an expression parameter longer than DynamoDB's 4096-byte limit,
25/// measured on the raw string as sent: aliases are not substituted and
26/// whitespace is not normalised before counting. Called at each expression
27/// parser's entry so every surface (Update/Condition/Filter/KeyCondition/
28/// Projection) is guarded.
29pub(crate) fn check_expression_size(expr: &str) -> Result<(), String> {
30    if expr.len() > MAX_EXPRESSION_BYTES {
31        return Err(format!(
32            "Expression size has exceeded the maximum allowed size; expression size: {}",
33            expr.len()
34        ));
35    }
36    Ok(())
37}
38
39/// Resolve an attribute name, handling `#name` substitution.
40pub fn resolve_name(
41    name: &str,
42    attr_names: &Option<HashMap<String, String>>,
43) -> Result<String, String> {
44    if name.starts_with('#') {
45        match attr_names {
46            Some(map) => map.get(name).cloned().ok_or_else(|| {
47                format!(
48                    "Value provided in ExpressionAttributeNames unused in expressions: keys: {{{name}}}"
49                )
50            }),
51            None => Err(format!(
52                "An expression attribute name used in the document path is not defined; attribute name: {name}"
53            )),
54        }
55    } else {
56        Ok(name.to_string())
57    }
58}
59
60/// Resolve an attribute value reference `:name`.
61pub fn resolve_value<'a>(
62    name: &str,
63    attr_values: &'a Option<HashMap<String, AttributeValue>>,
64) -> Result<&'a AttributeValue, String> {
65    match attr_values {
66        Some(map) => map.get(name).ok_or_else(|| {
67            format!(
68                "Value provided in ExpressionAttributeValues unused in expressions: keys: {{{name}}}"
69            )
70        }),
71        None => Err(format!(
72            "An expression attribute value used in expression is not defined; attribute value: {name}"
73        )),
74    }
75}
76
77/// Wrapper around expression attribute names/values that tracks which entries are used.
78///
79/// Use `RefCell` for interior mutability so it can be passed as `&TrackedExpressionAttributes`
80/// (not `&mut`) through all expression evaluation functions.
81pub struct TrackedExpressionAttributes<'a> {
82    pub names: &'a Option<HashMap<String, String>>,
83    pub values: &'a Option<HashMap<String, AttributeValue>>,
84    used_names: RefCell<HashSet<String>>,
85    used_values: RefCell<HashSet<String>>,
86    /// When false, resolve_name/resolve_value skip HashSet insertions.
87    /// Used in the per-item hot loop where tracking has already been done pre-loop.
88    tracking_enabled: bool,
89}
90
91impl<'a> TrackedExpressionAttributes<'a> {
92    pub fn new(
93        names: &'a Option<HashMap<String, String>>,
94        values: &'a Option<HashMap<String, AttributeValue>>,
95    ) -> Self {
96        Self {
97            names,
98            values,
99            used_names: RefCell::new(HashSet::new()),
100            used_values: RefCell::new(HashSet::new()),
101            tracking_enabled: true,
102        }
103    }
104
105    /// Create a variant that skips tracking. Name/value resolution still works
106    /// but HashSet insertions are skipped. Use in hot loops where tracking has
107    /// already been done by `track_condition_expr` pre-loop.
108    pub fn without_tracking(
109        names: &'a Option<HashMap<String, String>>,
110        values: &'a Option<HashMap<String, AttributeValue>>,
111    ) -> Self {
112        Self {
113            names,
114            values,
115            used_names: RefCell::new(HashSet::new()),
116            used_values: RefCell::new(HashSet::new()),
117            tracking_enabled: false,
118        }
119    }
120
121    /// Resolve an attribute name, handling `#name` substitution, and track usage.
122    pub fn resolve_name(&self, name: &str) -> Result<String, String> {
123        if name.starts_with('#') {
124            if self.tracking_enabled {
125                self.used_names.borrow_mut().insert(name.to_string());
126            }
127            match self.names {
128                Some(map) => map.get(name).cloned().ok_or_else(|| {
129                    format!(
130                        "An expression attribute name used in the document path is not defined; attribute name: {name}"
131                    )
132                }),
133                None => Err(format!(
134                    "An expression attribute name used in the document path is not defined; attribute name: {name}"
135                )),
136            }
137        } else {
138            Ok(name.to_string())
139        }
140    }
141
142    /// Resolve an attribute value reference `:name` and track usage.
143    pub fn resolve_value<'b>(&'b self, name: &str) -> Result<&'a AttributeValue, String> {
144        if self.tracking_enabled {
145            self.used_values.borrow_mut().insert(name.to_string());
146        }
147        match self.values {
148            Some(map) => map.get(name).ok_or_else(|| {
149                format!(
150                    "An expression attribute value used in expression is not defined; attribute value: {name}"
151                )
152            }),
153            None => Err(format!(
154                "An expression attribute value used in expression is not defined; attribute value: {name}"
155            )),
156        }
157    }
158
159    /// Pre-register all `#name` and `:value` references found in a parsed condition expression.
160    /// This ensures they are tracked even if the expression is never evaluated (e.g., no items).
161    pub fn track_condition_expr(&self, expr: &condition::ConditionExpr) {
162        self.walk_condition(expr);
163    }
164
165    fn walk_condition(&self, expr: &condition::ConditionExpr) {
166        match expr {
167            condition::ConditionExpr::Comparison { left, op: _, right } => {
168                self.walk_operand(left);
169                self.walk_operand(right);
170            }
171            condition::ConditionExpr::Between { operand, lo, hi } => {
172                self.walk_operand(operand);
173                self.walk_operand(lo);
174                self.walk_operand(hi);
175            }
176            condition::ConditionExpr::In { operand, values } => {
177                self.walk_operand(operand);
178                for v in values {
179                    self.walk_operand(v);
180                }
181            }
182            condition::ConditionExpr::AttributeExists(path)
183            | condition::ConditionExpr::AttributeNotExists(path) => {
184                self.walk_path_elements(path);
185            }
186            condition::ConditionExpr::AttributeType(path, op) => {
187                self.walk_path_elements(path);
188                self.walk_operand(op);
189            }
190            condition::ConditionExpr::BeginsWith(a, b)
191            | condition::ConditionExpr::Contains(a, b) => {
192                self.walk_operand(a);
193                self.walk_operand(b);
194            }
195            condition::ConditionExpr::And(l, r) | condition::ConditionExpr::Or(l, r) => {
196                self.walk_condition(l);
197                self.walk_condition(r);
198            }
199            condition::ConditionExpr::Not(inner) => {
200                self.walk_condition(inner);
201            }
202        }
203    }
204
205    fn walk_operand(&self, operand: &condition::Operand) {
206        match operand {
207            condition::Operand::Path(path) | condition::Operand::Size(path) => {
208                self.walk_path_elements(path);
209            }
210            condition::Operand::ValueRef(name) => {
211                self.used_values.borrow_mut().insert(name.clone());
212            }
213        }
214    }
215
216    fn walk_path_elements(&self, path: &[PathElement]) {
217        for elem in path {
218            if let PathElement::Attribute(name) = elem {
219                if name.starts_with('#') {
220                    self.used_names.borrow_mut().insert(name.clone());
221                }
222            }
223        }
224    }
225
226    /// Pre-register all `#name` references found in a parsed projection expression.
227    pub fn track_projection_expr(&self, proj: &projection::ProjectionExpr) {
228        for path in &proj.paths {
229            self.walk_path_elements(path);
230        }
231    }
232
233    /// Pre-register all `#name` and `:value` references found in a parsed update expression.
234    pub fn track_update_expr(&self, expr: &update::UpdateExpr) {
235        for action in &expr.set_actions {
236            self.walk_path_elements(&action.path);
237            self.walk_set_value(&action.value);
238        }
239        for path in &expr.remove_actions {
240            self.walk_path_elements(path);
241        }
242        for action in &expr.add_actions {
243            self.walk_path_elements(&action.path);
244            self.used_values
245                .borrow_mut()
246                .insert(action.value_ref.clone());
247        }
248        for action in &expr.delete_actions {
249            self.walk_path_elements(&action.path);
250            self.used_values
251                .borrow_mut()
252                .insert(action.value_ref.clone());
253        }
254    }
255
256    fn walk_set_value(&self, value: &update::SetValue) {
257        match value {
258            update::SetValue::Operand(op) => self.walk_set_operand(op),
259            update::SetValue::Plus(l, r) | update::SetValue::Minus(l, r) => {
260                self.walk_set_operand(l);
261                self.walk_set_operand(r);
262            }
263        }
264    }
265
266    fn walk_set_operand(&self, operand: &update::SetOperand) {
267        match operand {
268            update::SetOperand::Path(path) => self.walk_path_elements(path),
269            update::SetOperand::ValueRef(name) => {
270                self.used_values.borrow_mut().insert(name.clone());
271            }
272            update::SetOperand::IfNotExists(path, default) => {
273                self.walk_path_elements(path);
274                self.walk_set_operand(default);
275            }
276            update::SetOperand::ListAppend(a, b) => {
277                self.walk_set_operand(a);
278                self.walk_set_operand(b);
279            }
280            update::SetOperand::Group(inner) => self.walk_set_value(inner),
281        }
282    }
283
284    /// Pre-register all `#name` and `:value` references in a parsed key condition.
285    /// Note: key_condition::parse already resolves names, so we track the original
286    /// value refs that will be resolved later via resolve_values.
287    pub fn track_key_condition(&self, cond: &key_condition::KeyCondition) {
288        self.used_values
289            .borrow_mut()
290            .insert(cond.pk_value_ref.clone());
291        if let Some(ref sk) = cond.sk_condition {
292            match sk {
293                key_condition::SortKeyCondition::Eq(_, vr)
294                | key_condition::SortKeyCondition::Lt(_, vr)
295                | key_condition::SortKeyCondition::Le(_, vr)
296                | key_condition::SortKeyCondition::Gt(_, vr)
297                | key_condition::SortKeyCondition::Ge(_, vr)
298                | key_condition::SortKeyCondition::BeginsWith(_, vr) => {
299                    self.used_values.borrow_mut().insert(vr.clone());
300                }
301                key_condition::SortKeyCondition::Between(_, lo, hi) => {
302                    self.used_values.borrow_mut().insert(lo.clone());
303                    self.used_values.borrow_mut().insert(hi.clone());
304                }
305            }
306        }
307    }
308
309    /// Check for unused names/values. Returns an error listing all unused keys.
310    pub fn check_unused(&self) -> Result<(), DynoxideError> {
311        let used_names = self.used_names.borrow();
312        let used_values = self.used_values.borrow();
313
314        if let Some(names_map) = self.names {
315            let unused: Vec<&String> = names_map
316                .keys()
317                .filter(|k| !used_names.contains(*k))
318                .collect();
319            if !unused.is_empty() {
320                let mut keys: Vec<&str> = unused.iter().map(|s| s.as_str()).collect();
321                keys.sort();
322                return Err(DynoxideError::ValidationException(format!(
323                    "Value provided in ExpressionAttributeNames unused in expressions: keys: {{{}}}",
324                    keys.join(", ")
325                )));
326            }
327        }
328
329        if let Some(values_map) = self.values {
330            let unused: Vec<&String> = values_map
331                .keys()
332                .filter(|k| !used_values.contains(*k))
333                .collect();
334            if !unused.is_empty() {
335                let mut keys: Vec<&str> = unused.iter().map(|s| s.as_str()).collect();
336                keys.sort();
337                return Err(DynoxideError::ValidationException(format!(
338                    "Value provided in ExpressionAttributeValues unused in expressions: keys: {{{}}}",
339                    keys.join(", ")
340                )));
341            }
342        }
343
344        Ok(())
345    }
346}
347
348/// Resolve `#name` references in path elements, tracking usage via a `TrackedExpressionAttributes`.
349///
350/// This is the single implementation used by condition, projection, and update modules.
351pub fn resolve_path_elements(
352    path: &[PathElement],
353    tracker: &TrackedExpressionAttributes,
354) -> Result<Vec<PathElement>, String> {
355    path.iter()
356        .map(|elem| match elem {
357            PathElement::Attribute(name) if name.starts_with('#') => {
358                let resolved = tracker.resolve_name(name)?;
359                Ok(PathElement::Attribute(resolved))
360            }
361            other => Ok(other.clone()),
362        })
363        .collect()
364}
365
366/// Evaluate a condition expression without tracking attribute usage.
367///
368/// This is a convenience wrapper for callers (e.g., import filters) that don't need
369/// unused-attribute validation. Uses the no-tracking variant to avoid RefCell/HashSet overhead.
370pub fn evaluate_without_tracking(
371    expr: &condition::ConditionExpr,
372    item: &HashMap<String, AttributeValue>,
373    attr_names: &Option<HashMap<String, String>>,
374    attr_values: &Option<HashMap<String, AttributeValue>>,
375) -> Result<bool, String> {
376    let tracker = TrackedExpressionAttributes::without_tracking(attr_names, attr_values);
377    condition::evaluate(expr, item, &tracker)
378}
379
380/// Navigate a document path into an item, returning the attribute value at that path.
381pub fn resolve_path(
382    item: &HashMap<String, AttributeValue>,
383    path: &[PathElement],
384) -> Option<AttributeValue> {
385    if path.is_empty() {
386        return None;
387    }
388
389    let first = match &path[0] {
390        PathElement::Attribute(name) => item.get(name)?,
391        PathElement::Index(_) => return None,
392    };
393
394    let mut current = first.clone();
395    for element in &path[1..] {
396        match element {
397            PathElement::Attribute(name) => {
398                if let AttributeValue::M(map) = &current {
399                    current = map.get(name)?.clone();
400                } else {
401                    return None;
402                }
403            }
404            PathElement::Index(i) => {
405                if let AttributeValue::L(list) = &current {
406                    current = list.get(*i)?.clone();
407                } else {
408                    return None;
409                }
410            }
411        }
412    }
413
414    Some(current)
415}
416
417/// Set a value at a document path, creating intermediate maps/lists as needed.
418/// Returns the modified item.
419pub fn set_path(
420    item: &mut HashMap<String, AttributeValue>,
421    path: &[PathElement],
422    value: AttributeValue,
423) -> Result<(), String> {
424    if path.is_empty() {
425        return Err("Empty path".to_string());
426    }
427
428    if path.len() == 1 {
429        match &path[0] {
430            PathElement::Attribute(name) => {
431                item.insert(name.clone(), value);
432                Ok(())
433            }
434            PathElement::Index(_) => Err("Cannot index into top-level item".to_string()),
435        }
436    } else {
437        let first_name = match &path[0] {
438            PathElement::Attribute(name) => name.clone(),
439            PathElement::Index(_) => return Err("Cannot index into top-level item".to_string()),
440        };
441
442        // DynamoDB does NOT auto-create top-level attributes for nested paths.
443        // SET missing.nested = :v fails if "missing" doesn't exist on the item.
444        // Only SET topLevel = :v (path.len() == 1, handled above) creates new attributes.
445        let entry = match item.get_mut(&first_name) {
446            Some(e) => e,
447            None => {
448                return Err(
449                    "The document path provided in the update expression is invalid for update"
450                        .to_string(),
451                );
452            }
453        };
454
455        set_nested(entry, &path[1..], value)
456    }
457}
458
459/// Extend a list with NULL padding so that `list[target_len - 1]` is valid.
460fn pad_list_to(list: &mut Vec<AttributeValue>, target_len: usize) {
461    while list.len() < target_len {
462        list.push(AttributeValue::NULL(true));
463    }
464}
465
466fn set_nested(
467    current: &mut AttributeValue,
468    path: &[PathElement],
469    value: AttributeValue,
470) -> Result<(), String> {
471    if path.is_empty() {
472        return Err("Empty remaining path".to_string());
473    }
474
475    // Auto-promote NULL to the structure type needed by the next path element.
476    // This handles the case where list padding created NULL placeholders that
477    // need to become Maps or Lists for deeper path navigation.
478    if matches!(current, AttributeValue::NULL(_)) {
479        match &path[0] {
480            PathElement::Attribute(_) => {
481                *current = AttributeValue::M(HashMap::new());
482            }
483            PathElement::Index(_) => {
484                *current = AttributeValue::L(Vec::new());
485            }
486        }
487    }
488
489    if path.len() == 1 {
490        match &path[0] {
491            PathElement::Attribute(name) => {
492                if let AttributeValue::M(map) = current {
493                    map.insert(name.clone(), value);
494                    Ok(())
495                } else {
496                    Err(
497                        "The document path provided in the update expression is invalid for update"
498                            .to_string(),
499                    )
500                }
501            }
502            PathElement::Index(i) => {
503                if let AttributeValue::L(list) = current {
504                    pad_list_to(list, *i + 1);
505                    list[*i] = value;
506                    Ok(())
507                } else {
508                    Err(
509                        "The document path provided in the update expression is invalid for update"
510                            .to_string(),
511                    )
512                }
513            }
514        }
515    } else {
516        match &path[0] {
517            PathElement::Attribute(name) => {
518                if let AttributeValue::M(map) = current {
519                    // DynamoDB does NOT auto-create intermediate map entries.
520                    // The key must already exist to navigate deeper.
521                    match map.get_mut(name) {
522                        Some(entry) => set_nested(entry, &path[1..], value),
523                        None => Err(
524                            "The document path provided in the update expression is invalid for update"
525                                .to_string(),
526                        ),
527                    }
528                } else {
529                    Err(
530                        "The document path provided in the update expression is invalid for update"
531                            .to_string(),
532                    )
533                }
534            }
535            PathElement::Index(i) => {
536                if let AttributeValue::L(list) = current {
537                    pad_list_to(list, *i + 1);
538                    set_nested(&mut list[*i], &path[1..], value)
539                } else {
540                    Err(
541                        "The document path provided in the update expression is invalid for update"
542                            .to_string(),
543                    )
544                }
545            }
546        }
547    }
548}
549
550/// Remove a value at a document path.
551pub fn remove_path(
552    item: &mut HashMap<String, AttributeValue>,
553    path: &[PathElement],
554) -> Result<(), String> {
555    if path.is_empty() {
556        return Err("Empty path".to_string());
557    }
558
559    if path.len() == 1 {
560        match &path[0] {
561            PathElement::Attribute(name) => {
562                item.remove(name);
563                Ok(())
564            }
565            PathElement::Index(_) => Err("Cannot index into top-level item".to_string()),
566        }
567    } else {
568        let first_name = match &path[0] {
569            PathElement::Attribute(name) => name.clone(),
570            PathElement::Index(_) => return Err("Cannot index into top-level item".to_string()),
571        };
572
573        if let Some(entry) = item.get_mut(&first_name) {
574            remove_nested(entry, &path[1..])
575        } else {
576            Ok(()) // Path doesn't exist, nothing to remove
577        }
578    }
579}
580
581fn remove_nested(current: &mut AttributeValue, path: &[PathElement]) -> Result<(), String> {
582    if path.is_empty() {
583        return Err("Empty remaining path".to_string());
584    }
585
586    if path.len() == 1 {
587        match &path[0] {
588            PathElement::Attribute(name) => {
589                if let AttributeValue::M(map) = current {
590                    map.remove(name);
591                    Ok(())
592                } else {
593                    Ok(()) // Not a map, nothing to remove
594                }
595            }
596            PathElement::Index(i) => {
597                if let AttributeValue::L(list) = current {
598                    if *i < list.len() {
599                        list.remove(*i);
600                    }
601                    Ok(())
602                } else {
603                    Ok(()) // Not a list, nothing to remove
604                }
605            }
606        }
607    } else {
608        match &path[0] {
609            PathElement::Attribute(name) => {
610                if let AttributeValue::M(map) = current {
611                    if let Some(entry) = map.get_mut(name) {
612                        remove_nested(entry, &path[1..])
613                    } else {
614                        Ok(())
615                    }
616                } else {
617                    Ok(())
618                }
619            }
620            PathElement::Index(i) => {
621                if let AttributeValue::L(list) = current {
622                    if let Some(entry) = list.get_mut(*i) {
623                        remove_nested(entry, &path[1..])
624                    } else {
625                        Ok(())
626                    }
627                } else {
628                    Ok(())
629                }
630            }
631        }
632    }
633}
634
635/// Element in a document path (e.g., `a.b[0].c`).
636#[derive(Debug, Clone, PartialEq)]
637pub enum PathElement {
638    Attribute(String),
639    Index(usize),
640}
641
642/// Format a resolved path for overlap error messages: `[a]`, `[a, b]`, `[a, [0]]`.
643/// Shared by the ProjectionExpression and UpdateExpression overlap checks.
644pub(crate) fn format_path_for_error(path: &[PathElement]) -> String {
645    let parts: Vec<String> = path
646        .iter()
647        .map(|elem| match elem {
648            PathElement::Attribute(name) => name.clone(),
649            PathElement::Index(i) => format!("[{i}]"),
650        })
651        .collect();
652    format!("[{}]", parts.join(", "))
653}