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
12use serde_json::{Map, Value};
13use vantage_core::{Result, error};
14use vantage_expressions::{DeferredFn, Expression, Expressive, ExpressiveEnum};
15
16use crate::graphql::types::AnyGraphqlType;
17
18/// How a `GraphqlCondition` is rendered into a GraphQL argument object.
19///
20/// In YAML (`graphql: { dialect: … }`) it is spelled `generic` or
21/// `hasura`; any other value fails to deserialise.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
23#[serde(rename_all = "lowercase")]
24#[non_exhaustive]
25pub enum FilterDialect {
26    /// Hasura-style: `{ field: { _eq: v } }`, `_and`/`_or`/`_not`.
27    Hasura,
28    /// Flat-argument schemas like SpaceX: `{ field: v }`. Equality
29    /// only — non-eq operators fail at render time. The default.
30    #[default]
31    Generic,
32}
33
34/// The set of comparison/logical operators a `FieldCondition` can use.
35///
36/// Whether a given dialect can render a given op is decided at render
37/// time — see `GraphqlCondition::render`.
38#[derive(Clone, Debug, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum GraphqlOp {
41    /// `field = value`
42    Eq,
43    /// `field != value`
44    Ne,
45    /// `field > value`
46    Gt,
47    /// `field >= value`
48    Gte,
49    /// `field < value`
50    Lt,
51    /// `field <= value`
52    Lte,
53    /// `field` is one of the values in an array.
54    In,
55    /// `field` is none of the values in an array.
56    NotIn,
57    /// Case-sensitive pattern match (`%` and `_` wildcards).
58    Like,
59    /// Case-insensitive pattern match (`%` and `_` wildcards).
60    ILike,
61    /// `field` is null; the value is ignored.
62    IsNull,
63    /// `field` is not null; the value is ignored.
64    IsNotNull,
65}
66
67impl GraphqlOp {
68    /// Hasura operator name (`_eq`, `_gt`, …). Returns `None` for ops
69    /// Hasura can't express verbatim.
70    pub fn hasura_key(&self) -> Option<&'static str> {
71        Some(match self {
72            Self::Eq => "_eq",
73            Self::Ne => "_neq",
74            Self::Gt => "_gt",
75            Self::Gte => "_gte",
76            Self::Lt => "_lt",
77            Self::Lte => "_lte",
78            Self::In => "_in",
79            Self::NotIn => "_nin",
80            Self::Like => "_like",
81            Self::ILike => "_ilike",
82            Self::IsNull => "_is_null",
83            Self::IsNotNull => "_is_null",
84        })
85    }
86}
87
88/// A single `field <op> value` clause. Build one with
89/// [`FieldCondition::new`].
90#[derive(Clone, Debug)]
91#[non_exhaustive]
92pub struct FieldCondition {
93    /// The column name the clause tests.
94    pub field: String,
95    /// The comparison.
96    pub op: GraphqlOp,
97    /// The operand, already in JSON form.
98    pub value: Value,
99}
100
101impl FieldCondition {
102    /// A `field <op> value` clause.
103    pub fn new(field: impl Into<String>, op: GraphqlOp, value: Value) -> Self {
104        Self {
105            field: field.into(),
106            op,
107            value,
108        }
109    }
110}
111
112/// Structured filter for GraphQL requests. Built by the operator trait
113/// (`GraphqlOperation` in `operation.rs`) and rendered at fetch time.
114#[derive(Clone)]
115#[non_exhaustive]
116pub enum GraphqlCondition {
117    /// One `field <op> value` clause.
118    Field(FieldCondition),
119    /// Like [`Self::Field`] but the value is resolved at fetch time.
120    /// Used by relationship traversal — `with_many`/`with_one` builds
121    /// one of these when the parent's foreign-key value isn't known
122    /// until the parent is fetched. The deferred resolves to a scalar
123    /// (the FK value); render-time wraps it in the dialect's `_eq`-
124    /// equivalent and merges it into the filter. Build one with
125    /// [`GraphqlCondition::deferred_field`].
126    #[non_exhaustive]
127    DeferredField {
128        /// The column name the clause tests.
129        field: String,
130        /// The comparison.
131        op: GraphqlOp,
132        /// Produces the operand at fetch time.
133        value_fn: DeferredFn<AnyGraphqlType>,
134    },
135    /// Every part holds.
136    And(Vec<GraphqlCondition>),
137    /// At least one part holds. Hasura only.
138    Or(Vec<GraphqlCondition>),
139    /// The inner condition does not hold. Hasura only.
140    Not(Box<GraphqlCondition>),
141    /// Resolved at fetch time — produces a complete filter sub-object
142    /// that already matches the surrounding dialect. Use [`Self::DeferredField`]
143    /// instead unless you genuinely need to compute a non-`field op value`
144    /// shape dynamically.
145    Deferred(DeferredFn<AnyGraphqlType>),
146}
147
148impl std::fmt::Debug for GraphqlCondition {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Self::Field(fc) => write!(f, "Field({:?} {:?} {})", fc.field, fc.op, fc.value),
152            Self::DeferredField { field, op, .. } => {
153                write!(f, "DeferredField({:?} {:?} <pending>)", field, op)
154            }
155            Self::And(parts) => f.debug_tuple("And").field(parts).finish(),
156            Self::Or(parts) => f.debug_tuple("Or").field(parts).finish(),
157            Self::Not(inner) => f.debug_tuple("Not").field(inner).finish(),
158            Self::Deferred(_) => write!(f, "Deferred(..)"),
159        }
160    }
161}
162
163impl GraphqlCondition {
164    /// Build a simple `field = value` condition. Convenience for callers
165    /// that have a value already converted to JSON.
166    pub fn eq(field: impl Into<String>, value: impl Into<Value>) -> Self {
167        Self::Field(FieldCondition::new(field, GraphqlOp::Eq, value.into()))
168    }
169
170    /// A `field <op> value` clause whose value `value_fn` produces at fetch
171    /// time; see [`Self::DeferredField`].
172    pub fn deferred_field(
173        field: impl Into<String>,
174        op: GraphqlOp,
175        value_fn: DeferredFn<AnyGraphqlType>,
176    ) -> Self {
177        Self::DeferredField {
178            field: field.into(),
179            op,
180            value_fn,
181        }
182    }
183
184    /// Render this condition as a JSON object suitable for use as a
185    /// GraphQL argument (typically the `where:` arg in Hasura, or the
186    /// `find:` arg in flat-argument schemas).
187    ///
188    /// Deferred branches are resolved here. Resolution may make
189    /// out-of-band fetches (e.g. for relationship traversal), so the
190    /// method is async.
191    pub fn render<'a>(
192        &'a self,
193        dialect: FilterDialect,
194    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
195        Box::pin(async move {
196            match self {
197                Self::Field(fc) => render_field(fc, dialect),
198                Self::DeferredField {
199                    field,
200                    op,
201                    value_fn,
202                } => {
203                    let resolved = value_fn.call().await?;
204                    let value = match resolved {
205                        ExpressiveEnum::Scalar(v) => v.into_value(),
206                        other => {
207                            return Err(error!(
208                                "DeferredField resolved to non-scalar",
209                                got = format!("{:?}", other)
210                            ));
211                        }
212                    };
213                    let fc = FieldCondition::new(field.clone(), op.clone(), value);
214                    render_field(&fc, dialect)
215                }
216                Self::And(parts) => {
217                    let mut rendered = Vec::with_capacity(parts.len());
218                    for p in parts {
219                        rendered.push(p.render(dialect).await?);
220                    }
221                    combine_and(rendered, dialect)
222                }
223                Self::Or(parts) => {
224                    if matches!(dialect, FilterDialect::Generic) {
225                        return Err(error!(
226                            "Generic dialect does not support OR; switch to Hasura"
227                        ));
228                    }
229                    let mut rendered = Vec::with_capacity(parts.len());
230                    for p in parts {
231                        rendered.push(p.render(dialect).await?);
232                    }
233                    Ok(Value::Object({
234                        let mut m = Map::new();
235                        m.insert("_or".into(), Value::Array(rendered));
236                        m
237                    }))
238                }
239                Self::Not(inner) => {
240                    if matches!(dialect, FilterDialect::Generic) {
241                        return Err(error!(
242                            "Generic dialect does not support NOT; switch to Hasura"
243                        ));
244                    }
245                    let inner_rendered = inner.render(dialect).await?;
246                    Ok(Value::Object({
247                        let mut m = Map::new();
248                        m.insert("_not".into(), inner_rendered);
249                        m
250                    }))
251                }
252                Self::Deferred(deferred) => {
253                    let resolved = deferred.call().await?;
254                    let inner = match resolved {
255                        ExpressiveEnum::Scalar(v) => v.into_value(),
256                        other => {
257                            return Err(error!(
258                                "GraphqlCondition::Deferred resolved to non-scalar",
259                                got = format!("{:?}", other)
260                            ));
261                        }
262                    };
263                    match inner {
264                        Value::Object(_) => Ok(inner),
265                        other => Err(error!(
266                            "Deferred condition must resolve to a JSON object",
267                            got = format!("{:?}", other)
268                        )),
269                    }
270                }
271            }
272        })
273    }
274
275    /// Synchronous counterpart to [`render`](Self::render), for previewing a
276    /// query without sending it.
277    ///
278    /// Every arm matches `render` but one: a deferred value does not exist yet
279    /// — it is produced by calling back to the caller at fetch time — so
280    /// rendering it here would mean doing the very work a preview exists to
281    /// avoid. Those become a `**deferred(...)` marker, which is not valid
282    /// GraphQL and is not meant to be: it marks the one place the previewed
283    /// document and the sent one differ.
284    ///
285    /// Literal conditions — most of them, and all the ones written by hand in
286    /// YAML — render identically to what gets sent.
287    pub fn render_preview(&self, dialect: FilterDialect) -> Result<Value> {
288        match self {
289            Self::Field(fc) => render_field(fc, dialect),
290            Self::DeferredField { field, op, .. } => {
291                Ok(Value::String(format!("**deferred({} {:?})", field, op)))
292            }
293            Self::And(parts) => {
294                let rendered = parts
295                    .iter()
296                    .map(|p| p.render_preview(dialect))
297                    .collect::<Result<Vec<Value>>>()?;
298                combine_and(rendered, dialect)
299            }
300            Self::Or(parts) => {
301                if matches!(dialect, FilterDialect::Generic) {
302                    return Err(error!(
303                        "Generic dialect does not support OR; switch to Hasura"
304                    ));
305                }
306                let rendered = parts
307                    .iter()
308                    .map(|p| p.render_preview(dialect))
309                    .collect::<Result<Vec<Value>>>()?;
310                Ok(Value::Object({
311                    let mut m = Map::new();
312                    m.insert("_or".into(), Value::Array(rendered));
313                    m
314                }))
315            }
316            Self::Not(inner) => {
317                if matches!(dialect, FilterDialect::Generic) {
318                    return Err(error!(
319                        "Generic dialect does not support NOT; switch to Hasura"
320                    ));
321                }
322                Ok(Value::Object({
323                    let mut m = Map::new();
324                    m.insert("_not".into(), inner.render_preview(dialect)?);
325                    m
326                }))
327            }
328            Self::Deferred(_) => Ok(Value::String("**deferred()".into())),
329        }
330    }
331}
332
333fn render_field(fc: &FieldCondition, dialect: FilterDialect) -> Result<Value> {
334    match dialect {
335        FilterDialect::Hasura => {
336            let mut inner = Map::new();
337            let key = fc.op.hasura_key().ok_or_else(|| {
338                error!(
339                    "Operator not supported in Hasura dialect",
340                    op = format!("{:?}", fc.op)
341                )
342            })?;
343            // Hasura's `_is_null` takes a Bool, which is how IsNotNull is spelled.
344            let value = match fc.op {
345                GraphqlOp::IsNull => Value::Bool(true),
346                GraphqlOp::IsNotNull => Value::Bool(false),
347                _ => fc.value.clone(),
348            };
349            inner.insert(key.into(), value);
350            let mut outer = Map::new();
351            outer.insert(fc.field.clone(), Value::Object(inner));
352            Ok(Value::Object(outer))
353        }
354        FilterDialect::Generic => {
355            if fc.op != GraphqlOp::Eq {
356                return Err(error!(
357                    "Generic dialect supports only equality; got non-eq operator",
358                    field = fc.field.clone(),
359                    op = format!("{:?}", fc.op)
360                ));
361            }
362            let mut m = Map::new();
363            m.insert(fc.field.clone(), fc.value.clone());
364            Ok(Value::Object(m))
365        }
366    }
367}
368
369/// AND-combine rendered sub-conditions according to dialect.
370fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
371    match dialect {
372        FilterDialect::Hasura => {
373            // Distinct field keys in one object are an implicit AND; `_and`
374            // is needed only when two parts share a key.
375            let mut merged = Map::new();
376            let mut collision = false;
377            for p in &parts {
378                if let Value::Object(obj) = p {
379                    for k in obj.keys() {
380                        if merged.contains_key(k) {
381                            collision = true;
382                            break;
383                        }
384                    }
385                    if collision {
386                        break;
387                    }
388                    if let Value::Object(obj) = p.clone() {
389                        for (k, v) in obj {
390                            merged.insert(k, v);
391                        }
392                    }
393                }
394            }
395            if collision {
396                Ok(Value::Object({
397                    let mut m = Map::new();
398                    m.insert("_and".into(), Value::Array(parts));
399                    m
400                }))
401            } else {
402                Ok(Value::Object(merged))
403            }
404        }
405        FilterDialect::Generic => {
406            // A flat object can't hold two conditions on one field; fail
407            // rather than silently keep one.
408            let mut merged = Map::new();
409            for p in parts {
410                if let Value::Object(obj) = p {
411                    for (k, v) in obj {
412                        if merged.contains_key(&k) {
413                            return Err(error!(
414                                "Generic dialect can't express two conditions on the same field",
415                                field = k
416                            ));
417                        }
418                        merged.insert(k, v);
419                    }
420                }
421            }
422            Ok(Value::Object(merged))
423        }
424    }
425}
426
427impl From<FieldCondition> for GraphqlCondition {
428    fn from(fc: FieldCondition) -> Self {
429        Self::Field(fc)
430    }
431}
432
433/// `GraphqlCondition` is `Expressive`, which satisfies the blanket bound on
434/// [`GraphqlOperation`](crate::GraphqlOperation).
435impl Expressive<AnyGraphqlType> for GraphqlCondition {
436    fn expr(&self) -> Expression<AnyGraphqlType> {
437        Expression::new(format!("{:?}", self), vec![])
438    }
439}