1use serde_json::{Map, Value};
17use vantage_core::{Result, error};
18use vantage_expressions::{DeferredFn, Expression, Expressive, ExpressiveEnum};
19
20use crate::graphql::types::AnyGraphqlType;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum FilterDialect {
25 Hasura,
27 Generic,
30}
31
32#[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 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#[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#[derive(Clone)]
94pub enum GraphqlCondition {
95 Field(FieldCondition),
96 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 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 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 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 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
288fn 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 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
326fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
328 match dialect {
329 FilterDialect::Hasura => {
330 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 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
386impl From<FieldCondition> for GraphqlCondition {
389 fn from(fc: FieldCondition) -> Self {
390 Self::Field(fc)
391 }
392}
393
394impl 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}