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