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}