Skip to main content

vantage_aws/dynamodb/
condition.rs

1//! DynamoDB condition DSL.
2//!
3//! `DynamoCondition` carries the pieces DynamoDB's `FilterExpression`
4//! consumes — an expression string referencing `#name` / `:value`
5//! placeholders, plus the maps that bind them. `In` is deferred so
6//! relationship traversal can run a source query at execution time.
7//!
8//! Multiple conditions on a `Table` get combined via [`resolve`], which
9//! mangles placeholders to be globally unique within one Scan request
10//! and joins the rendered fragments with ` AND `.
11
12use std::pin::Pin;
13use std::sync::Arc;
14
15use indexmap::IndexMap;
16use vantage_core::Result;
17
18use super::types::AttributeValue;
19
20/// Future returned by a deferred condition fetch (e.g. a relationship
21/// traversal that lists the source table to discover the IN values).
22pub type ValueListFuture =
23    Pin<Box<dyn std::future::Future<Output = Result<Vec<AttributeValue>>> + Send>>;
24
25/// Producer for an IN-clause's value list. Cloned every time the
26/// condition is evaluated, so backends are free to memoize externally.
27pub type ValueListFn = Arc<dyn Fn() -> ValueListFuture + Send + Sync>;
28
29/// A DynamoDB filter condition.
30///
31/// - `Expr` is a fully-rendered expression with already-mangled placeholders.
32/// - `In` defers resolution until a source query has run (relationship traversal).
33/// - `And` combines siblings with implicit `AND`.
34#[derive(Clone)]
35pub enum DynamoCondition {
36    Expr {
37        expression: String,
38        names: IndexMap<String, String>,
39        values: IndexMap<String, AttributeValue>,
40    },
41    In {
42        field: String,
43        values: ValueListFn,
44    },
45    And(Vec<DynamoCondition>),
46}
47
48impl std::fmt::Debug for DynamoCondition {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::Expr {
52                expression,
53                names,
54                values,
55            } => f
56                .debug_struct("Expr")
57                .field("expression", expression)
58                .field("names", names)
59                .field("values", values)
60                .finish(),
61            Self::In { field, .. } => f
62                .debug_struct("In")
63                .field("field", field)
64                .finish_non_exhaustive(),
65            Self::And(conds) => f.debug_tuple("And").field(conds).finish(),
66        }
67    }
68}
69
70impl DynamoCondition {
71    /// Build a `field = value` condition.
72    pub fn eq(field: impl Into<String>, value: impl Into<AttributeValue>) -> Self {
73        let field = field.into();
74        let mut names = IndexMap::new();
75        let mut values = IndexMap::new();
76        names.insert("#f".to_string(), field);
77        values.insert(":v".to_string(), value.into());
78        Self::Expr {
79            expression: "#f = :v".to_string(),
80            names,
81            values,
82        }
83    }
84
85    /// Build a `begins_with(field, prefix)` condition. Used for sort-key
86    /// prefix filtering in single-table designs.
87    pub fn begins_with(field: impl Into<String>, prefix: impl Into<String>) -> Self {
88        let mut names = IndexMap::new();
89        let mut values = IndexMap::new();
90        names.insert("#f".to_string(), field.into());
91        values.insert(":v".to_string(), AttributeValue::S(prefix.into()));
92        Self::Expr {
93            expression: "begins_with(#f, :v)".to_string(),
94            names,
95            values,
96        }
97    }
98}
99
100/// Resolved condition pieces ready to fold into a Scan/Query request.
101#[derive(Debug, Clone, Default)]
102pub struct ResolvedFilter {
103    pub expression: String,
104    pub names: IndexMap<String, String>,
105    pub values: IndexMap<String, AttributeValue>,
106}
107
108impl ResolvedFilter {
109    pub fn is_empty(&self) -> bool {
110        self.expression.is_empty()
111    }
112}
113
114/// Walk a list of conditions, mangle placeholders to be globally unique,
115/// and produce a single `FilterExpression` plus combined attribute maps.
116pub async fn resolve_conditions<'a, I>(conditions: I) -> Result<ResolvedFilter>
117where
118    I: IntoIterator<Item = &'a DynamoCondition>,
119{
120    let mut state = MangleState::default();
121    let mut fragments = Vec::new();
122    for cond in conditions {
123        if let Some(frag) = resolve_one(cond, &mut state).await? {
124            fragments.push(frag);
125        }
126    }
127    let expression = match fragments.len() {
128        0 => String::new(),
129        1 => fragments.into_iter().next().unwrap(),
130        _ => fragments
131            .into_iter()
132            .map(|f| format!("({})", f))
133            .collect::<Vec<_>>()
134            .join(" AND "),
135    };
136    Ok(ResolvedFilter {
137        expression,
138        names: state.names,
139        values: state.values,
140    })
141}
142
143/// Resolve a single condition, returning the rendered expression
144/// fragment with mangled placeholders. Returns `None` for empty `And`s.
145fn resolve_one<'a>(
146    cond: &'a DynamoCondition,
147    state: &'a mut MangleState,
148) -> Pin<Box<dyn std::future::Future<Output = Result<Option<String>>> + Send + 'a>> {
149    Box::pin(async move {
150        match cond {
151            DynamoCondition::Expr {
152                expression,
153                names,
154                values,
155            } => {
156                let mut rendered = expression.clone();
157                for (placeholder, name) in names {
158                    let new_ph = state.fresh_name(name.clone());
159                    rendered = replace_placeholder(&rendered, placeholder, &new_ph);
160                }
161                for (placeholder, value) in values {
162                    let new_ph = state.fresh_value(value.clone());
163                    rendered = replace_placeholder(&rendered, placeholder, &new_ph);
164                }
165                Ok(Some(rendered))
166            }
167            DynamoCondition::In { field, values } => {
168                let resolved = (values)().await?;
169                if resolved.is_empty() {
170                    // `field IN ()` is invalid in DynamoDB. An empty
171                    // source set means "match nothing" — emit a
172                    // tautologically-false fragment.
173                    let name_ph = state.fresh_name(field.clone());
174                    return Ok(Some(format!(
175                        "attribute_not_exists({}) AND attribute_exists({})",
176                        name_ph, name_ph
177                    )));
178                }
179                let name_ph = state.fresh_name(field.clone());
180                let value_phs: Vec<String> =
181                    resolved.into_iter().map(|v| state.fresh_value(v)).collect();
182                Ok(Some(format!("{} IN ({})", name_ph, value_phs.join(", "))))
183            }
184            DynamoCondition::And(children) => {
185                let mut parts = Vec::new();
186                for child in children {
187                    if let Some(p) = resolve_one(child, state).await? {
188                        parts.push(p);
189                    }
190                }
191                Ok(if parts.is_empty() {
192                    None
193                } else {
194                    Some(parts.join(" AND "))
195                })
196            }
197        }
198    })
199}
200
201/// Replace every occurrence of `from` in `s` with `to`. Used to swap
202/// each condition's local placeholder names for globally-unique ones.
203fn replace_placeholder(s: &str, from: &str, to: &str) -> String {
204    s.replace(from, to)
205}
206
207#[derive(Default)]
208struct MangleState {
209    names: IndexMap<String, String>,
210    values: IndexMap<String, AttributeValue>,
211    name_seq: usize,
212    value_seq: usize,
213}
214
215impl MangleState {
216    fn fresh_name(&mut self, attr: String) -> String {
217        let ph = format!("#n{}", self.name_seq);
218        self.name_seq += 1;
219        self.names.insert(ph.clone(), attr);
220        ph
221    }
222
223    fn fresh_value(&mut self, value: AttributeValue) -> String {
224        let ph = format!(":v{}", self.value_seq);
225        self.value_seq += 1;
226        self.values.insert(ph.clone(), value);
227        ph
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[tokio::test]
236    async fn empty_input_yields_empty_filter() {
237        let r = resolve_conditions(std::iter::empty()).await.unwrap();
238        assert!(r.is_empty());
239    }
240
241    #[tokio::test]
242    async fn single_eq_renders_with_mangled_placeholders() {
243        let cond = DynamoCondition::eq("name", AttributeValue::S("Alice".into()));
244        let r = resolve_conditions(std::iter::once(&cond)).await.unwrap();
245        assert_eq!(r.expression, "#n0 = :v0");
246        assert_eq!(r.names.get("#n0").unwrap(), "name");
247        assert_eq!(
248            r.values.get(":v0").unwrap(),
249            &AttributeValue::S("Alice".into())
250        );
251    }
252
253    #[tokio::test]
254    async fn two_eqs_get_unique_placeholders() {
255        let a = DynamoCondition::eq("name", AttributeValue::S("Alice".into()));
256        let b = DynamoCondition::eq("city", AttributeValue::S("Riga".into()));
257        let r = resolve_conditions([&a, &b]).await.unwrap();
258        assert_eq!(r.expression, "(#n0 = :v0) AND (#n1 = :v1)");
259        assert_eq!(r.names.get("#n0").unwrap(), "name");
260        assert_eq!(r.names.get("#n1").unwrap(), "city");
261    }
262
263    #[tokio::test]
264    async fn deferred_in_resolves_to_in_expression() {
265        let values = Arc::new(|| -> ValueListFuture {
266            Box::pin(async move {
267                Ok(vec![
268                    AttributeValue::S("a".into()),
269                    AttributeValue::S("b".into()),
270                ])
271            })
272        });
273        let cond = DynamoCondition::In {
274            field: "bakery_id".to_string(),
275            values,
276        };
277        let r = resolve_conditions(std::iter::once(&cond)).await.unwrap();
278        assert_eq!(r.expression, "#n0 IN (:v0, :v1)");
279        assert_eq!(r.names.get("#n0").unwrap(), "bakery_id");
280        assert_eq!(r.values.len(), 2);
281    }
282}