Skip to main content

vantage_aws/
condition.rs

1//! Conditions for AWS-backed Vantage tables.
2//!
3//! AWS APIs only accept exact-match filters, so the only operator
4//! that survives the round-trip is equality. `In` and `Deferred` are
5//! here to support `with_one` / `with_many` traversal — they must
6//! collapse to a single value at execute time, otherwise the call
7//! errors loudly.
8
9use ciborium::Value as CborValue;
10use serde_json::Value as JsonValue;
11use vantage_expressions::Expression;
12
13#[derive(Clone)]
14pub enum AwsCondition {
15    /// `field == value`. Folds into the JSON request body verbatim.
16    Eq { field: String, value: CborValue },
17    /// `field == value` from a literal set. A single-element set
18    /// collapses to `Eq`; zero or multi-element is a hard error.
19    In {
20        field: String,
21        values: Vec<CborValue>,
22    },
23    /// `field == value` where the value comes from another query.
24    /// Resolved at execute time; the source must yield exactly one
25    /// value.
26    Deferred {
27        field: String,
28        source: Expression<CborValue>,
29    },
30}
31
32// Manual Debug — `Expression<CborValue>` doesn't impl Debug because
33// `ciborium::Value` doesn't impl Display. We render structurally
34// without leaning on the inner expression's own Debug.
35impl std::fmt::Debug for AwsCondition {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Eq { field, value } => f
39                .debug_struct("Eq")
40                .field("field", field)
41                .field("value", value)
42                .finish(),
43            Self::In { field, values } => f
44                .debug_struct("In")
45                .field("field", field)
46                .field("values", values)
47                .finish(),
48            Self::Deferred { field, source } => f
49                .debug_struct("Deferred")
50                .field("field", field)
51                .field("source.template", &source.template)
52                .field("source.params", &source.parameters.len())
53                .finish(),
54        }
55    }
56}
57
58impl AwsCondition {
59    pub fn eq(field: impl Into<String>, value: impl Into<CborValue>) -> Self {
60        Self::Eq {
61            field: field.into(),
62            value: value.into(),
63        }
64    }
65
66    pub fn in_<I, V>(field: impl Into<String>, values: I) -> Self
67    where
68        I: IntoIterator<Item = V>,
69        V: Into<CborValue>,
70    {
71        Self::In {
72            field: field.into(),
73            values: values.into_iter().map(Into::into).collect(),
74        }
75    }
76
77    pub fn field(&self) -> &str {
78        match self {
79            Self::Eq { field, .. } | Self::In { field, .. } | Self::Deferred { field, .. } => field,
80        }
81    }
82}
83
84/// `field == value`. Shorthand for [`AwsCondition::eq`].
85///
86/// ```
87/// # use vantage_aws::eq;
88/// let cond = eq("logGroupNamePrefix", "/aws/lambda/");
89/// ```
90pub fn eq(field: impl Into<String>, value: impl Into<CborValue>) -> AwsCondition {
91    AwsCondition::eq(field, value)
92}
93
94/// `field IN values` (literal set). Shorthand for [`AwsCondition::in_`].
95/// Remember the single-value rule — a multi-element set will error
96/// at execute time.
97pub fn in_<I, V>(field: impl Into<String>, values: I) -> AwsCondition
98where
99    I: IntoIterator<Item = V>,
100    V: Into<CborValue>,
101{
102    AwsCondition::in_(field, values)
103}
104
105/// Resolve conditions to a flat list of `(field, value)` pairs. Both
106/// JSON-1.1 and Query body builders sit on top of this — they only
107/// differ in how they format the result for the wire.
108///
109/// Errors on zero- or multi-element `In`. Panics if a `Deferred`
110/// reached this point — those must be resolved upstream
111/// (see `AwsAccount::resolve_conditions`).
112fn resolved_pairs(conditions: &[AwsCondition]) -> vantage_core::Result<Vec<(String, CborValue)>> {
113    let mut out = Vec::with_capacity(conditions.len());
114    for cond in conditions {
115        match cond {
116            AwsCondition::Eq { field, value } => {
117                out.push((field.clone(), value.clone()));
118            }
119            AwsCondition::In { field, values } => match values.as_slice() {
120                [single] => out.push((field.clone(), single.clone())),
121                [] => {
122                    return Err(vantage_core::error!(
123                        "AwsCondition::In with zero values is not representable",
124                        field = field.as_str()
125                    ));
126                }
127                _ => {
128                    return Err(vantage_core::error!(
129                        "AwsCondition::In with more than one value is not supported \
130                         by AWS — relations must traverse from a single parent",
131                        field = field.as_str(),
132                        count = values.len()
133                    ));
134                }
135            },
136            AwsCondition::Deferred { field, .. } => {
137                return Err(vantage_core::error!(
138                    "Internal: Deferred condition reached body builder unresolved \
139                     — AwsAccount::resolve_conditions should have materialised it",
140                    field = field.as_str()
141                ));
142            }
143        }
144    }
145    Ok(out)
146}
147
148/// Fold conditions into a JSON object suitable for an AWS JSON-1.1
149/// request body.
150pub(crate) fn build_json1_body(
151    conditions: &[AwsCondition],
152) -> vantage_core::Result<serde_json::Map<String, JsonValue>> {
153    let pairs = resolved_pairs(conditions)?;
154    let mut body = serde_json::Map::new();
155    for (field, value) in pairs {
156        body.insert(field, cbor_to_json(&value));
157    }
158    Ok(body)
159}
160
161/// Fold conditions into form-encoded `(key, value)` pairs for the AWS
162/// Query protocol. CBOR scalars get rendered to strings (text → as-is,
163/// integers / floats / bools → `to_string`); compound values become a
164/// best-effort JSON-flavoured string. Query APIs in v0 only see
165/// scalars, so the JSON fallback is purely defensive.
166pub(crate) fn build_query_form(
167    conditions: &[AwsCondition],
168) -> vantage_core::Result<Vec<(String, String)>> {
169    let pairs = resolved_pairs(conditions)?;
170    Ok(pairs
171        .into_iter()
172        .map(|(k, v)| (k, cbor_to_string(&v)))
173        .collect())
174}
175
176fn cbor_to_string(v: &CborValue) -> String {
177    match v {
178        CborValue::Text(s) => s.clone(),
179        CborValue::Integer(i) => {
180            let n: i128 = (*i).into();
181            n.to_string()
182        }
183        CborValue::Float(f) => f.to_string(),
184        CborValue::Bool(b) => b.to_string(),
185        CborValue::Null => String::new(),
186        other => cbor_to_json(other).to_string(),
187    }
188}
189
190/// CBOR → JSON via ciborium's serde bridge. Used at the wire boundary
191/// when emitting request bodies. Falls back to `null` for the rare
192/// CBOR shapes JSON can't represent (which AWS conditions don't
193/// produce).
194fn cbor_to_json(v: &CborValue) -> JsonValue {
195    v.deserialized::<JsonValue>().unwrap_or(JsonValue::Null)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde_json::json;
202
203    #[test]
204    fn eq_folds_into_body() {
205        let conds = [eq("logGroupNamePrefix", "/aws/lambda/")];
206        let body = build_json1_body(&conds).unwrap();
207        assert_eq!(body["logGroupNamePrefix"], json!("/aws/lambda/"));
208    }
209
210    #[test]
211    fn single_element_in_collapses_to_eq() {
212        let conds = [in_(
213            "logGroupName",
214            vec![CborValue::from("/aws/lambda/foo")],
215        )];
216        let body = build_json1_body(&conds).unwrap();
217        assert_eq!(body["logGroupName"], json!("/aws/lambda/foo"));
218    }
219
220    #[test]
221    fn multi_element_in_errors() {
222        let conds = [in_(
223            "logGroupName",
224            vec![CborValue::from("a"), CborValue::from("b")],
225        )];
226        let err = build_json1_body(&conds).unwrap_err();
227        assert!(format!("{err}").contains("more than one value"));
228    }
229
230    #[test]
231    fn empty_in_errors() {
232        let conds = [AwsCondition::In {
233            field: "x".into(),
234            values: vec![],
235        }];
236        assert!(build_json1_body(&conds).is_err());
237    }
238
239    #[test]
240    fn deferred_in_build_body_is_internal_error() {
241        // The body builder should never see Deferred — resolve_conditions
242        // turns them into Eq first. If one slips through, surface it
243        // loudly rather than silently dropping the filter.
244        let conds = [AwsCondition::Deferred {
245            field: "x".into(),
246            source: Expression::new("noop", vec![]),
247        }];
248        let err = build_json1_body(&conds).unwrap_err();
249        assert!(format!("{err}").contains("Deferred"));
250    }
251
252    #[test]
253    fn multiple_eqs_compose() {
254        let conds = [
255            eq("logGroupName", "/aws/lambda/foo"),
256            eq("startTime", 1_700_000_000_000i64),
257        ];
258        let body = build_json1_body(&conds).unwrap();
259        assert_eq!(body["logGroupName"], json!("/aws/lambda/foo"));
260        assert_eq!(body["startTime"], json!(1_700_000_000_000i64));
261    }
262
263    #[test]
264    fn query_form_renders_strings_and_numbers() {
265        let conds = [eq("UserName", "alice"), eq("MaxItems", 50i64)];
266        let form = build_query_form(&conds).unwrap();
267        assert_eq!(
268            form,
269            vec![
270                ("UserName".to_string(), "alice".to_string()),
271                ("MaxItems".to_string(), "50".to_string()),
272            ]
273        );
274    }
275}