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