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            update::SetOperand::Group(inner) => self.walk_set_value(inner),
263        }
264    }
265
266    /// Pre-register all `#name` and `:value` references in a parsed key condition.
267    /// Note: key_condition::parse already resolves names, so we track the original
268    /// value refs that will be resolved later via resolve_values.
269    pub fn track_key_condition(&self, cond: &key_condition::KeyCondition) {
270        self.used_values
271            .borrow_mut()
272            .insert(cond.pk_value_ref.clone());
273        if let Some(ref sk) = cond.sk_condition {
274            match sk {
275                key_condition::SortKeyCondition::Eq(_, vr)
276                | key_condition::SortKeyCondition::Lt(_, vr)
277                | key_condition::SortKeyCondition::Le(_, vr)
278                | key_condition::SortKeyCondition::Gt(_, vr)
279                | key_condition::SortKeyCondition::Ge(_, vr)
280                | key_condition::SortKeyCondition::BeginsWith(_, vr) => {
281                    self.used_values.borrow_mut().insert(vr.clone());
282                }
283                key_condition::SortKeyCondition::Between(_, lo, hi) => {
284                    self.used_values.borrow_mut().insert(lo.clone());
285                    self.used_values.borrow_mut().insert(hi.clone());
286                }
287            }
288        }
289    }
290
291    /// Check for unused names/values. Returns an error listing all unused keys.
292    pub fn check_unused(&self) -> Result<(), DynoxideError> {
293        let used_names = self.used_names.borrow();
294        let used_values = self.used_values.borrow();
295
296        if let Some(names_map) = self.names {
297            let unused: Vec<&String> = names_map
298                .keys()
299                .filter(|k| !used_names.contains(*k))
300                .collect();
301            if !unused.is_empty() {
302                let mut keys: Vec<&str> = unused.iter().map(|s| s.as_str()).collect();
303                keys.sort();
304                return Err(DynoxideError::ValidationException(format!(
305                    "Value provided in ExpressionAttributeNames unused in expressions: keys: {{{}}}",
306                    keys.join(", ")
307                )));
308            }
309        }
310
311        if let Some(values_map) = self.values {
312            let unused: Vec<&String> = values_map
313                .keys()
314                .filter(|k| !used_values.contains(*k))
315                .collect();
316            if !unused.is_empty() {
317                let mut keys: Vec<&str> = unused.iter().map(|s| s.as_str()).collect();
318                keys.sort();
319                return Err(DynoxideError::ValidationException(format!(
320                    "Value provided in ExpressionAttributeValues unused in expressions: keys: {{{}}}",
321                    keys.join(", ")
322                )));
323            }
324        }
325
326        Ok(())
327    }
328}
329
330/// Resolve `#name` references in path elements, tracking usage via a `TrackedExpressionAttributes`.
331///
332/// This is the single implementation used by condition, projection, and update modules.
333pub fn resolve_path_elements(
334    path: &[PathElement],
335    tracker: &TrackedExpressionAttributes,
336) -> Result<Vec<PathElement>, String> {
337    path.iter()
338        .map(|elem| match elem {
339            PathElement::Attribute(name) if name.starts_with('#') => {
340                let resolved = tracker.resolve_name(name)?;
341                Ok(PathElement::Attribute(resolved))
342            }
343            other => Ok(other.clone()),
344        })
345        .collect()
346}
347
348/// Evaluate a condition expression without tracking attribute usage.
349///
350/// This is a convenience wrapper for callers (e.g., import filters) that don't need
351/// unused-attribute validation. Uses the no-tracking variant to avoid RefCell/HashSet overhead.
352pub fn evaluate_without_tracking(
353    expr: &condition::ConditionExpr,
354    item: &HashMap<String, AttributeValue>,
355    attr_names: &Option<HashMap<String, String>>,
356    attr_values: &Option<HashMap<String, AttributeValue>>,
357) -> Result<bool, String> {
358    let tracker = TrackedExpressionAttributes::without_tracking(attr_names, attr_values);
359    condition::evaluate(expr, item, &tracker)
360}
361
362/// Navigate a document path into an item, returning the attribute value at that path.
363pub fn resolve_path(
364    item: &HashMap<String, AttributeValue>,
365    path: &[PathElement],
366) -> Option<AttributeValue> {
367    if path.is_empty() {
368        return None;
369    }
370
371    let first = match &path[0] {
372        PathElement::Attribute(name) => item.get(name)?,
373        PathElement::Index(_) => return None,
374    };
375
376    let mut current = first.clone();
377    for element in &path[1..] {
378        match element {
379            PathElement::Attribute(name) => {
380                if let AttributeValue::M(map) = &current {
381                    current = map.get(name)?.clone();
382                } else {
383                    return None;
384                }
385            }
386            PathElement::Index(i) => {
387                if let AttributeValue::L(list) = &current {
388                    current = list.get(*i)?.clone();
389                } else {
390                    return None;
391                }
392            }
393        }
394    }
395
396    Some(current)
397}
398
399/// Set a value at a document path, creating intermediate maps/lists as needed.
400/// Returns the modified item.
401pub fn set_path(
402    item: &mut HashMap<String, AttributeValue>,
403    path: &[PathElement],
404    value: AttributeValue,
405) -> Result<(), String> {
406    if path.is_empty() {
407        return Err("Empty path".to_string());
408    }
409
410    if path.len() == 1 {
411        match &path[0] {
412            PathElement::Attribute(name) => {
413                item.insert(name.clone(), value);
414                Ok(())
415            }
416            PathElement::Index(_) => Err("Cannot index into top-level item".to_string()),
417        }
418    } else {
419        let first_name = match &path[0] {
420            PathElement::Attribute(name) => name.clone(),
421            PathElement::Index(_) => return Err("Cannot index into top-level item".to_string()),
422        };
423
424        // DynamoDB does NOT auto-create top-level attributes for nested paths.
425        // SET missing.nested = :v fails if "missing" doesn't exist on the item.
426        // Only SET topLevel = :v (path.len() == 1, handled above) creates new attributes.
427        let entry = match item.get_mut(&first_name) {
428            Some(e) => e,
429            None => {
430                return Err(
431                    "The document path provided in the update expression is invalid for update"
432                        .to_string(),
433                );
434            }
435        };
436
437        set_nested(entry, &path[1..], value)
438    }
439}
440
441/// Extend a list with NULL padding so that `list[target_len - 1]` is valid.
442fn pad_list_to(list: &mut Vec<AttributeValue>, target_len: usize) {
443    while list.len() < target_len {
444        list.push(AttributeValue::NULL(true));
445    }
446}
447
448fn set_nested(
449    current: &mut AttributeValue,
450    path: &[PathElement],
451    value: AttributeValue,
452) -> Result<(), String> {
453    if path.is_empty() {
454        return Err("Empty remaining path".to_string());
455    }
456
457    // Auto-promote NULL to the structure type needed by the next path element.
458    // This handles the case where list padding created NULL placeholders that
459    // need to become Maps or Lists for deeper path navigation.
460    if matches!(current, AttributeValue::NULL(_)) {
461        match &path[0] {
462            PathElement::Attribute(_) => {
463                *current = AttributeValue::M(HashMap::new());
464            }
465            PathElement::Index(_) => {
466                *current = AttributeValue::L(Vec::new());
467            }
468        }
469    }
470
471    if path.len() == 1 {
472        match &path[0] {
473            PathElement::Attribute(name) => {
474                if let AttributeValue::M(map) = current {
475                    map.insert(name.clone(), value);
476                    Ok(())
477                } else {
478                    Err(
479                        "The document path provided in the update expression is invalid for update"
480                            .to_string(),
481                    )
482                }
483            }
484            PathElement::Index(i) => {
485                if let AttributeValue::L(list) = current {
486                    pad_list_to(list, *i + 1);
487                    list[*i] = value;
488                    Ok(())
489                } else {
490                    Err(
491                        "The document path provided in the update expression is invalid for update"
492                            .to_string(),
493                    )
494                }
495            }
496        }
497    } else {
498        match &path[0] {
499            PathElement::Attribute(name) => {
500                if let AttributeValue::M(map) = current {
501                    // DynamoDB does NOT auto-create intermediate map entries.
502                    // The key must already exist to navigate deeper.
503                    match map.get_mut(name) {
504                        Some(entry) => set_nested(entry, &path[1..], value),
505                        None => Err(
506                            "The document path provided in the update expression is invalid for update"
507                                .to_string(),
508                        ),
509                    }
510                } else {
511                    Err(
512                        "The document path provided in the update expression is invalid for update"
513                            .to_string(),
514                    )
515                }
516            }
517            PathElement::Index(i) => {
518                if let AttributeValue::L(list) = current {
519                    pad_list_to(list, *i + 1);
520                    set_nested(&mut list[*i], &path[1..], value)
521                } else {
522                    Err(
523                        "The document path provided in the update expression is invalid for update"
524                            .to_string(),
525                    )
526                }
527            }
528        }
529    }
530}
531
532/// Remove a value at a document path.
533pub fn remove_path(
534    item: &mut HashMap<String, AttributeValue>,
535    path: &[PathElement],
536) -> Result<(), String> {
537    if path.is_empty() {
538        return Err("Empty path".to_string());
539    }
540
541    if path.len() == 1 {
542        match &path[0] {
543            PathElement::Attribute(name) => {
544                item.remove(name);
545                Ok(())
546            }
547            PathElement::Index(_) => Err("Cannot index into top-level item".to_string()),
548        }
549    } else {
550        let first_name = match &path[0] {
551            PathElement::Attribute(name) => name.clone(),
552            PathElement::Index(_) => return Err("Cannot index into top-level item".to_string()),
553        };
554
555        if let Some(entry) = item.get_mut(&first_name) {
556            remove_nested(entry, &path[1..])
557        } else {
558            Ok(()) // Path doesn't exist, nothing to remove
559        }
560    }
561}
562
563fn remove_nested(current: &mut AttributeValue, path: &[PathElement]) -> Result<(), String> {
564    if path.is_empty() {
565        return Err("Empty remaining path".to_string());
566    }
567
568    if path.len() == 1 {
569        match &path[0] {
570            PathElement::Attribute(name) => {
571                if let AttributeValue::M(map) = current {
572                    map.remove(name);
573                    Ok(())
574                } else {
575                    Ok(()) // Not a map, nothing to remove
576                }
577            }
578            PathElement::Index(i) => {
579                if let AttributeValue::L(list) = current {
580                    if *i < list.len() {
581                        list.remove(*i);
582                    }
583                    Ok(())
584                } else {
585                    Ok(()) // Not a list, nothing to remove
586                }
587            }
588        }
589    } else {
590        match &path[0] {
591            PathElement::Attribute(name) => {
592                if let AttributeValue::M(map) = current {
593                    if let Some(entry) = map.get_mut(name) {
594                        remove_nested(entry, &path[1..])
595                    } else {
596                        Ok(())
597                    }
598                } else {
599                    Ok(())
600                }
601            }
602            PathElement::Index(i) => {
603                if let AttributeValue::L(list) = current {
604                    if let Some(entry) = list.get_mut(*i) {
605                        remove_nested(entry, &path[1..])
606                    } else {
607                        Ok(())
608                    }
609                } else {
610                    Ok(())
611                }
612            }
613        }
614    }
615}
616
617/// Element in a document path (e.g., `a.b[0].c`).
618#[derive(Debug, Clone, PartialEq)]
619pub enum PathElement {
620    Attribute(String),
621    Index(usize),
622}