Skip to main content

vantage_api_client/graphql/
condition.rs

1//! `GraphqlCondition` — the structured filter type for the GraphQL adapter.
2//!
3//! Conditions are kept abstract (field/op/value) and rendered to JSON at
4//! request time by the dialect attached to the data source. The two
5//! dialects that ship today:
6//!
7//! * [`FilterDialect::Hasura`] — `{ field: { _eq: v }, _and: [...], _or:
8//!   [...], _not: {...} }`. Full operator coverage.
9//! * [`FilterDialect::Generic`] — flat argument object: `{ field: v }`.
10//!   Equality only; non-eq operators error at render time. Used by
11//!   hand-rolled schemas like the SpaceX public API.
12//!
13//! Postgraphile / Relay-cursor styles can be added as further dialects
14//! without changing the condition surface.
15
16use serde_json::{Map, Value};
17use vantage_core::{Result, error};
18use vantage_expressions::{DeferredFn, Expression, Expressive, ExpressiveEnum};
19
20use crate::graphql::types::AnyGraphqlType;
21
22/// How a `GraphqlCondition` is rendered into a GraphQL argument object.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum FilterDialect {
25    /// Hasura-style: `{ field: { _eq: v } }`, `_and`/`_or`/`_not`.
26    Hasura,
27    /// Flat-argument schemas like SpaceX: `{ field: v }`. Equality
28    /// only — non-eq operators fail at render time.
29    Generic,
30}
31
32/// The set of comparison/logical operators a `FieldCondition` can use.
33///
34/// Whether a given dialect can render a given op is decided at render
35/// time — see `GraphqlCondition::render`.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum GraphqlOp {
38    Eq,
39    Ne,
40    Gt,
41    Gte,
42    Lt,
43    Lte,
44    In,
45    NotIn,
46    Like,
47    ILike,
48    IsNull,
49    IsNotNull,
50}
51
52impl GraphqlOp {
53    /// Hasura operator name (`_eq`, `_gt`, …). Returns `None` for ops
54    /// Hasura can't express verbatim.
55    pub fn hasura_key(&self) -> Option<&'static str> {
56        Some(match self {
57            Self::Eq => "_eq",
58            Self::Ne => "_neq",
59            Self::Gt => "_gt",
60            Self::Gte => "_gte",
61            Self::Lt => "_lt",
62            Self::Lte => "_lte",
63            Self::In => "_in",
64            Self::NotIn => "_nin",
65            Self::Like => "_like",
66            Self::ILike => "_ilike",
67            Self::IsNull => "_is_null",
68            Self::IsNotNull => "_is_null",
69        })
70    }
71}
72
73/// A single `field <op> value` clause.
74#[derive(Clone, Debug)]
75pub struct FieldCondition {
76    pub field: String,
77    pub op: GraphqlOp,
78    pub value: Value,
79}
80
81impl FieldCondition {
82    pub fn new(field: impl Into<String>, op: GraphqlOp, value: Value) -> Self {
83        Self {
84            field: field.into(),
85            op,
86            value,
87        }
88    }
89}
90
91/// Structured filter for GraphQL requests. Built by the operator trait
92/// (`GraphqlOperation` in `operation.rs`) and rendered at fetch time.
93#[derive(Clone)]
94pub enum GraphqlCondition {
95    Field(FieldCondition),
96    /// Like [`Self::Field`] but the value is resolved at fetch time.
97    /// Used by relationship traversal — `with_many`/`with_one` builds
98    /// one of these when the parent's foreign-key value isn't known
99    /// until the parent is fetched. The deferred resolves to a scalar
100    /// (the FK value); render-time wraps it in the dialect's `_eq`-
101    /// equivalent and merges it into the filter.
102    DeferredField {
103        field: String,
104        op: GraphqlOp,
105        value_fn: DeferredFn<AnyGraphqlType>,
106    },
107    And(Vec<GraphqlCondition>),
108    Or(Vec<GraphqlCondition>),
109    Not(Box<GraphqlCondition>),
110    /// Resolved at fetch time — produces a complete filter sub-object
111    /// that already matches the surrounding dialect. Use [`Self::DeferredField`]
112    /// instead unless you genuinely need to compute a non-`field op value`
113    /// shape dynamically.
114    Deferred(DeferredFn<AnyGraphqlType>),
115}
116
117impl std::fmt::Debug for GraphqlCondition {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::Field(fc) => write!(f, "Field({:?} {:?} {})", fc.field, fc.op, fc.value),
121            Self::DeferredField { field, op, .. } => {
122                write!(f, "DeferredField({:?} {:?} <pending>)", field, op)
123            }
124            Self::And(parts) => f.debug_tuple("And").field(parts).finish(),
125            Self::Or(parts) => f.debug_tuple("Or").field(parts).finish(),
126            Self::Not(inner) => f.debug_tuple("Not").field(inner).finish(),
127            Self::Deferred(_) => write!(f, "Deferred(..)"),
128        }
129    }
130}
131
132impl GraphqlCondition {
133    /// Build a simple `field = value` condition. Convenience for callers
134    /// that have a value already converted to JSON.
135    pub fn eq(field: impl Into<String>, value: impl Into<Value>) -> Self {
136        Self::Field(FieldCondition::new(field, GraphqlOp::Eq, value.into()))
137    }
138
139    /// Render this condition as a JSON object suitable for use as a
140    /// GraphQL argument (typically the `where:` arg in Hasura, or the
141    /// `find:` arg in flat-argument schemas).
142    ///
143    /// Deferred branches are resolved here. Resolution may make
144    /// out-of-band fetches (e.g. for relationship traversal), so the
145    /// method is async.
146    pub fn render<'a>(
147        &'a self,
148        dialect: FilterDialect,
149    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
150        Box::pin(async move {
151            match self {
152                Self::Field(fc) => render_field(fc, dialect),
153                Self::DeferredField {
154                    field,
155                    op,
156                    value_fn,
157                } => {
158                    let resolved = value_fn.call().await?;
159                    let value = match resolved {
160                        ExpressiveEnum::Scalar(v) => v.into_value(),
161                        other => {
162                            return Err(error!(
163                                "DeferredField resolved to non-scalar",
164                                got = format!("{:?}", other)
165                            ));
166                        }
167                    };
168                    let fc = FieldCondition::new(field.clone(), op.clone(), value);
169                    render_field(&fc, dialect)
170                }
171                Self::And(parts) => {
172                    let mut rendered = Vec::with_capacity(parts.len());
173                    for p in parts {
174                        rendered.push(p.render(dialect).await?);
175                    }
176                    combine_and(rendered, dialect)
177                }
178                Self::Or(parts) => {
179                    if matches!(dialect, FilterDialect::Generic) {
180                        return Err(error!(
181                            "Generic dialect does not support OR; switch to Hasura"
182                        ));
183                    }
184                    let mut rendered = Vec::with_capacity(parts.len());
185                    for p in parts {
186                        rendered.push(p.render(dialect).await?);
187                    }
188                    Ok(Value::Object({
189                        let mut m = Map::new();
190                        m.insert("_or".into(), Value::Array(rendered));
191                        m
192                    }))
193                }
194                Self::Not(inner) => {
195                    if matches!(dialect, FilterDialect::Generic) {
196                        return Err(error!(
197                            "Generic dialect does not support NOT; switch to Hasura"
198                        ));
199                    }
200                    let inner_rendered = inner.render(dialect).await?;
201                    Ok(Value::Object({
202                        let mut m = Map::new();
203                        m.insert("_not".into(), inner_rendered);
204                        m
205                    }))
206                }
207                Self::Deferred(deferred) => {
208                    let resolved = deferred.call().await?;
209                    let inner = match resolved {
210                        ExpressiveEnum::Scalar(v) => v.into_value(),
211                        other => {
212                            return Err(error!(
213                                "GraphqlCondition::Deferred resolved to non-scalar",
214                                got = format!("{:?}", other)
215                            ));
216                        }
217                    };
218                    match inner {
219                        Value::Object(_) => Ok(inner),
220                        other => Err(error!(
221                            "Deferred condition must resolve to a JSON object",
222                            got = format!("{:?}", other)
223                        )),
224                    }
225                }
226            }
227        })
228    }
229}
230
231// ── Helpers ──────────────────────────────────────────────────────────
232
233fn render_field(fc: &FieldCondition, dialect: FilterDialect) -> Result<Value> {
234    match dialect {
235        FilterDialect::Hasura => {
236            let mut inner = Map::new();
237            let key = fc.op.hasura_key().ok_or_else(|| {
238                error!(
239                    "Operator not supported in Hasura dialect",
240                    op = format!("{:?}", fc.op)
241                )
242            })?;
243            // Hasura's `_is_null` op takes a Bool; map IsNull → true, IsNotNull → false.
244            let value = match fc.op {
245                GraphqlOp::IsNull => Value::Bool(true),
246                GraphqlOp::IsNotNull => Value::Bool(false),
247                _ => fc.value.clone(),
248            };
249            inner.insert(key.into(), value);
250            let mut outer = Map::new();
251            outer.insert(fc.field.clone(), Value::Object(inner));
252            Ok(Value::Object(outer))
253        }
254        FilterDialect::Generic => {
255            if fc.op != GraphqlOp::Eq {
256                return Err(error!(
257                    "Generic dialect supports only equality; got non-eq operator",
258                    field = fc.field.clone(),
259                    op = format!("{:?}", fc.op)
260                ));
261            }
262            let mut m = Map::new();
263            m.insert(fc.field.clone(), fc.value.clone());
264            Ok(Value::Object(m))
265        }
266    }
267}
268
269/// AND-combine rendered sub-conditions according to dialect.
270fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
271    match dialect {
272        FilterDialect::Hasura => {
273            // Hasura allows merging field-keys directly: { foo: {_eq: 1}, bar: {_eq: 2} }
274            // is implicit AND. Use _and only when there are duplicate keys.
275            let mut merged = Map::new();
276            let mut collision = false;
277            for p in &parts {
278                if let Value::Object(obj) = p {
279                    for k in obj.keys() {
280                        if merged.contains_key(k) {
281                            collision = true;
282                            break;
283                        }
284                    }
285                    if collision {
286                        break;
287                    }
288                    if let Value::Object(obj) = p.clone() {
289                        for (k, v) in obj {
290                            merged.insert(k, v);
291                        }
292                    }
293                }
294            }
295            if collision {
296                Ok(Value::Object({
297                    let mut m = Map::new();
298                    m.insert("_and".into(), Value::Array(parts));
299                    m
300                }))
301            } else {
302                Ok(Value::Object(merged))
303            }
304        }
305        FilterDialect::Generic => {
306            // Flat-args schemas only support implicit AND via a shared
307            // object. If any key collides, the dialect can't represent
308            // it — surface that as an error rather than silently picking
309            // a winner.
310            let mut merged = Map::new();
311            for p in parts {
312                if let Value::Object(obj) = p {
313                    for (k, v) in obj {
314                        if merged.contains_key(&k) {
315                            return Err(error!(
316                                "Generic dialect can't express two conditions on the same field",
317                                field = k
318                            ));
319                        }
320                        merged.insert(k, v);
321                    }
322                }
323            }
324            Ok(Value::Object(merged))
325        }
326    }
327}
328
329// ── Conversions ──────────────────────────────────────────────────────
330
331impl From<FieldCondition> for GraphqlCondition {
332    fn from(fc: FieldCondition) -> Self {
333        Self::Field(fc)
334    }
335}
336
337/// `GraphqlCondition` is `Expressive` so it satisfies the blanket
338/// bound on the operation trait — `cond.eq(false)` shape works the
339/// same way as Mongo's chaining.
340impl Expressive<AnyGraphqlType> for GraphqlCondition {
341    fn expr(&self) -> Expression<AnyGraphqlType> {
342        Expression::new(format!("{:?}", self), vec![])
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use serde_json::json;
350
351    #[tokio::test]
352    async fn hasura_renders_eq_as_underscore_eq() {
353        let c = GraphqlCondition::Field(FieldCondition::new(
354            "mission_name",
355            GraphqlOp::Eq,
356            json!("FalconSat"),
357        ));
358        let r = c.render(FilterDialect::Hasura).await.unwrap();
359        assert_eq!(r, json!({ "mission_name": { "_eq": "FalconSat" } }));
360    }
361
362    #[tokio::test]
363    async fn generic_renders_eq_as_flat_field() {
364        let c = GraphqlCondition::Field(FieldCondition::new(
365            "mission_name",
366            GraphqlOp::Eq,
367            json!("FalconSat"),
368        ));
369        let r = c.render(FilterDialect::Generic).await.unwrap();
370        assert_eq!(r, json!({ "mission_name": "FalconSat" }));
371    }
372
373    #[tokio::test]
374    async fn generic_rejects_non_eq() {
375        let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
376        let err = c.render(FilterDialect::Generic).await.unwrap_err();
377        assert!(err.to_string().contains("equality"));
378    }
379
380    #[tokio::test]
381    async fn hasura_renders_gt() {
382        let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
383        let r = c.render(FilterDialect::Hasura).await.unwrap();
384        assert_eq!(r, json!({ "price": { "_gt": 100 } }));
385    }
386
387    #[tokio::test]
388    async fn hasura_renders_is_null_with_bool_arg() {
389        let c = GraphqlCondition::Field(FieldCondition::new(
390            "deleted_at",
391            GraphqlOp::IsNull,
392            Value::Null,
393        ));
394        let r = c.render(FilterDialect::Hasura).await.unwrap();
395        assert_eq!(r, json!({ "deleted_at": { "_is_null": true } }));
396    }
397
398    #[tokio::test]
399    async fn hasura_and_with_distinct_fields_merges_flat() {
400        let c = GraphqlCondition::And(vec![
401            GraphqlCondition::Field(FieldCondition::new("name", GraphqlOp::Eq, json!("Alice"))),
402            GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
403        ]);
404        let r = c.render(FilterDialect::Hasura).await.unwrap();
405        assert_eq!(
406            r,
407            json!({ "name": { "_eq": "Alice" }, "active": { "_eq": true } })
408        );
409    }
410
411    #[tokio::test]
412    async fn hasura_and_with_same_field_uses_explicit_and() {
413        let c = GraphqlCondition::And(vec![
414            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(10))),
415            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Lt, json!(100))),
416        ]);
417        let r = c.render(FilterDialect::Hasura).await.unwrap();
418        assert_eq!(
419            r,
420            json!({
421                "_and": [
422                    { "price": { "_gt": 10 } },
423                    { "price": { "_lt": 100 } }
424                ]
425            })
426        );
427    }
428
429    #[tokio::test]
430    async fn generic_and_with_same_field_errors() {
431        let c = GraphqlCondition::And(vec![
432            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(10))),
433            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(20))),
434        ]);
435        let err = c.render(FilterDialect::Generic).await.unwrap_err();
436        assert!(err.to_string().contains("same field"));
437    }
438
439    #[tokio::test]
440    async fn hasura_or_and_not() {
441        let c = GraphqlCondition::Not(Box::new(GraphqlCondition::Or(vec![
442            GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
443            GraphqlCondition::Field(FieldCondition::new("count", GraphqlOp::Gt, json!(0))),
444        ])));
445        let r = c.render(FilterDialect::Hasura).await.unwrap();
446        assert_eq!(
447            r,
448            json!({
449                "_not": {
450                    "_or": [
451                        { "active": { "_eq": true } },
452                        { "count": { "_gt": 0 } }
453                    ]
454                }
455            })
456        );
457    }
458
459    #[tokio::test]
460    async fn generic_rejects_or() {
461        let c = GraphqlCondition::Or(vec![
462            GraphqlCondition::Field(FieldCondition::new("a", GraphqlOp::Eq, json!(1))),
463            GraphqlCondition::Field(FieldCondition::new("b", GraphqlOp::Eq, json!(2))),
464        ]);
465        let err = c.render(FilterDialect::Generic).await.unwrap_err();
466        assert!(err.to_string().contains("OR"));
467    }
468}