1use serde_json::{Map, Value};
16use vantage_core::{Result, error};
17use vantage_expressions::{DeferredFn, Expression, Expressive, ExpressiveEnum};
18
19use crate::graphql::types::AnyGraphqlType;
20
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
26#[serde(rename_all = "lowercase")]
27#[non_exhaustive]
28pub enum FilterDialect {
29 Hasura,
31 #[default]
34 Generic,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum GraphqlOp {
44 Eq,
46 Ne,
48 Gt,
50 Gte,
52 Lt,
54 Lte,
56 In,
58 NotIn,
60 Like,
62 ILike,
64 IsNull,
66 IsNotNull,
68}
69
70impl GraphqlOp {
71 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#[derive(Clone, Debug)]
94#[non_exhaustive]
95pub struct FieldCondition {
96 pub field: String,
98 pub op: GraphqlOp,
100 pub value: Value,
102}
103
104impl FieldCondition {
105 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#[derive(Clone)]
118#[non_exhaustive]
119pub enum GraphqlCondition {
120 Field(FieldCondition),
122 #[non_exhaustive]
130 DeferredField {
131 field: String,
133 op: GraphqlOp,
135 value_fn: DeferredFn<AnyGraphqlType>,
137 },
138 And(Vec<GraphqlCondition>),
140 Or(Vec<GraphqlCondition>),
142 Not(Box<GraphqlCondition>),
144 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 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 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 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 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
336fn 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 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
374fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
376 match dialect {
377 FilterDialect::Hasura => {
378 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 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
434impl From<FieldCondition> for GraphqlCondition {
437 fn from(fc: FieldCondition) -> Self {
438 Self::Field(fc)
439 }
440}
441
442impl 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}