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    /// Synchronous counterpart to [`render`](Self::render), for previewing a
231    /// query without sending it.
232    ///
233    /// Every arm matches `render` but one: a deferred value does not exist yet
234    /// — it is produced by calling back to the caller at fetch time — so
235    /// rendering it here would mean doing the very work a preview exists to
236    /// avoid. Those become a `**deferred(...)` marker, which is not valid
237    /// GraphQL and is not meant to be: it marks the one place the previewed
238    /// document and the sent one differ.
239    ///
240    /// Literal conditions — most of them, and all the ones written by hand in
241    /// YAML — render identically to what gets sent.
242    pub fn render_preview(&self, dialect: FilterDialect) -> Result<Value> {
243        match self {
244            Self::Field(fc) => render_field(fc, dialect),
245            Self::DeferredField { field, op, .. } => {
246                Ok(Value::String(format!("**deferred({} {:?})", field, op)))
247            }
248            Self::And(parts) => {
249                let rendered = parts
250                    .iter()
251                    .map(|p| p.render_preview(dialect))
252                    .collect::<Result<Vec<Value>>>()?;
253                combine_and(rendered, dialect)
254            }
255            Self::Or(parts) => {
256                if matches!(dialect, FilterDialect::Generic) {
257                    return Err(error!(
258                        "Generic dialect does not support OR; switch to Hasura"
259                    ));
260                }
261                let rendered = parts
262                    .iter()
263                    .map(|p| p.render_preview(dialect))
264                    .collect::<Result<Vec<Value>>>()?;
265                Ok(Value::Object({
266                    let mut m = Map::new();
267                    m.insert("_or".into(), Value::Array(rendered));
268                    m
269                }))
270            }
271            Self::Not(inner) => {
272                if matches!(dialect, FilterDialect::Generic) {
273                    return Err(error!(
274                        "Generic dialect does not support NOT; switch to Hasura"
275                    ));
276                }
277                Ok(Value::Object({
278                    let mut m = Map::new();
279                    m.insert("_not".into(), inner.render_preview(dialect)?);
280                    m
281                }))
282            }
283            Self::Deferred(_) => Ok(Value::String("**deferred()".into())),
284        }
285    }
286}
287
288// ── Helpers ──────────────────────────────────────────────────────────
289
290fn render_field(fc: &FieldCondition, dialect: FilterDialect) -> Result<Value> {
291    match dialect {
292        FilterDialect::Hasura => {
293            let mut inner = Map::new();
294            let key = fc.op.hasura_key().ok_or_else(|| {
295                error!(
296                    "Operator not supported in Hasura dialect",
297                    op = format!("{:?}", fc.op)
298                )
299            })?;
300            // Hasura's `_is_null` op takes a Bool; map IsNull → true, IsNotNull → false.
301            let value = match fc.op {
302                GraphqlOp::IsNull => Value::Bool(true),
303                GraphqlOp::IsNotNull => Value::Bool(false),
304                _ => fc.value.clone(),
305            };
306            inner.insert(key.into(), value);
307            let mut outer = Map::new();
308            outer.insert(fc.field.clone(), Value::Object(inner));
309            Ok(Value::Object(outer))
310        }
311        FilterDialect::Generic => {
312            if fc.op != GraphqlOp::Eq {
313                return Err(error!(
314                    "Generic dialect supports only equality; got non-eq operator",
315                    field = fc.field.clone(),
316                    op = format!("{:?}", fc.op)
317                ));
318            }
319            let mut m = Map::new();
320            m.insert(fc.field.clone(), fc.value.clone());
321            Ok(Value::Object(m))
322        }
323    }
324}
325
326/// AND-combine rendered sub-conditions according to dialect.
327fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
328    match dialect {
329        FilterDialect::Hasura => {
330            // Hasura allows merging field-keys directly: { foo: {_eq: 1}, bar: {_eq: 2} }
331            // is implicit AND. Use _and only when there are duplicate keys.
332            let mut merged = Map::new();
333            let mut collision = false;
334            for p in &parts {
335                if let Value::Object(obj) = p {
336                    for k in obj.keys() {
337                        if merged.contains_key(k) {
338                            collision = true;
339                            break;
340                        }
341                    }
342                    if collision {
343                        break;
344                    }
345                    if let Value::Object(obj) = p.clone() {
346                        for (k, v) in obj {
347                            merged.insert(k, v);
348                        }
349                    }
350                }
351            }
352            if collision {
353                Ok(Value::Object({
354                    let mut m = Map::new();
355                    m.insert("_and".into(), Value::Array(parts));
356                    m
357                }))
358            } else {
359                Ok(Value::Object(merged))
360            }
361        }
362        FilterDialect::Generic => {
363            // Flat-args schemas only support implicit AND via a shared
364            // object. If any key collides, the dialect can't represent
365            // it — surface that as an error rather than silently picking
366            // a winner.
367            let mut merged = Map::new();
368            for p in parts {
369                if let Value::Object(obj) = p {
370                    for (k, v) in obj {
371                        if merged.contains_key(&k) {
372                            return Err(error!(
373                                "Generic dialect can't express two conditions on the same field",
374                                field = k
375                            ));
376                        }
377                        merged.insert(k, v);
378                    }
379                }
380            }
381            Ok(Value::Object(merged))
382        }
383    }
384}
385
386// ── Conversions ──────────────────────────────────────────────────────
387
388impl From<FieldCondition> for GraphqlCondition {
389    fn from(fc: FieldCondition) -> Self {
390        Self::Field(fc)
391    }
392}
393
394/// `GraphqlCondition` is `Expressive` so it satisfies the blanket
395/// bound on the operation trait — `cond.eq(false)` shape works the
396/// same way as Mongo's chaining.
397impl Expressive<AnyGraphqlType> for GraphqlCondition {
398    fn expr(&self) -> Expression<AnyGraphqlType> {
399        Expression::new(format!("{:?}", self), vec![])
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use serde_json::json;
407
408    #[tokio::test]
409    async fn hasura_renders_eq_as_underscore_eq() {
410        let c = GraphqlCondition::Field(FieldCondition::new(
411            "mission_name",
412            GraphqlOp::Eq,
413            json!("FalconSat"),
414        ));
415        let r = c.render(FilterDialect::Hasura).await.unwrap();
416        assert_eq!(r, json!({ "mission_name": { "_eq": "FalconSat" } }));
417    }
418
419    #[tokio::test]
420    async fn generic_renders_eq_as_flat_field() {
421        let c = GraphqlCondition::Field(FieldCondition::new(
422            "mission_name",
423            GraphqlOp::Eq,
424            json!("FalconSat"),
425        ));
426        let r = c.render(FilterDialect::Generic).await.unwrap();
427        assert_eq!(r, json!({ "mission_name": "FalconSat" }));
428    }
429
430    #[tokio::test]
431    async fn generic_rejects_non_eq() {
432        let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
433        let err = c.render(FilterDialect::Generic).await.unwrap_err();
434        assert!(err.to_string().contains("equality"));
435    }
436
437    #[tokio::test]
438    async fn hasura_renders_gt() {
439        let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
440        let r = c.render(FilterDialect::Hasura).await.unwrap();
441        assert_eq!(r, json!({ "price": { "_gt": 100 } }));
442    }
443
444    #[tokio::test]
445    async fn hasura_renders_is_null_with_bool_arg() {
446        let c = GraphqlCondition::Field(FieldCondition::new(
447            "deleted_at",
448            GraphqlOp::IsNull,
449            Value::Null,
450        ));
451        let r = c.render(FilterDialect::Hasura).await.unwrap();
452        assert_eq!(r, json!({ "deleted_at": { "_is_null": true } }));
453    }
454
455    #[tokio::test]
456    async fn hasura_and_with_distinct_fields_merges_flat() {
457        let c = GraphqlCondition::And(vec![
458            GraphqlCondition::Field(FieldCondition::new("name", GraphqlOp::Eq, json!("Alice"))),
459            GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
460        ]);
461        let r = c.render(FilterDialect::Hasura).await.unwrap();
462        assert_eq!(
463            r,
464            json!({ "name": { "_eq": "Alice" }, "active": { "_eq": true } })
465        );
466    }
467
468    #[tokio::test]
469    async fn hasura_and_with_same_field_uses_explicit_and() {
470        let c = GraphqlCondition::And(vec![
471            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(10))),
472            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Lt, json!(100))),
473        ]);
474        let r = c.render(FilterDialect::Hasura).await.unwrap();
475        assert_eq!(
476            r,
477            json!({
478                "_and": [
479                    { "price": { "_gt": 10 } },
480                    { "price": { "_lt": 100 } }
481                ]
482            })
483        );
484    }
485
486    #[tokio::test]
487    async fn generic_and_with_same_field_errors() {
488        let c = GraphqlCondition::And(vec![
489            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(10))),
490            GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(20))),
491        ]);
492        let err = c.render(FilterDialect::Generic).await.unwrap_err();
493        assert!(err.to_string().contains("same field"));
494    }
495
496    #[tokio::test]
497    async fn hasura_or_and_not() {
498        let c = GraphqlCondition::Not(Box::new(GraphqlCondition::Or(vec![
499            GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
500            GraphqlCondition::Field(FieldCondition::new("count", GraphqlOp::Gt, json!(0))),
501        ])));
502        let r = c.render(FilterDialect::Hasura).await.unwrap();
503        assert_eq!(
504            r,
505            json!({
506                "_not": {
507                    "_or": [
508                        { "active": { "_eq": true } },
509                        { "count": { "_gt": 0 } }
510                    ]
511                }
512            })
513        );
514    }
515
516    #[tokio::test]
517    async fn generic_rejects_or() {
518        let c = GraphqlCondition::Or(vec![
519            GraphqlCondition::Field(FieldCondition::new("a", GraphqlOp::Eq, json!(1))),
520            GraphqlCondition::Field(FieldCondition::new("b", GraphqlOp::Eq, json!(2))),
521        ]);
522        let err = c.render(FilterDialect::Generic).await.unwrap_err();
523        assert!(err.to_string().contains("OR"));
524    }
525}