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
231fn render_field(fc: &FieldCondition, dialect: FilterDialect) -> Result<Value> {
234 match dialect {
235 FilterDialect::Hasura => {
236 let mut inner = Map::new();
237 let key = fc.op.hasura_key().ok_or_else(|| {
238 error!(
239 "Operator not supported in Hasura dialect",
240 op = format!("{:?}", fc.op)
241 )
242 })?;
243 let value = match fc.op {
245 GraphqlOp::IsNull => Value::Bool(true),
246 GraphqlOp::IsNotNull => Value::Bool(false),
247 _ => fc.value.clone(),
248 };
249 inner.insert(key.into(), value);
250 let mut outer = Map::new();
251 outer.insert(fc.field.clone(), Value::Object(inner));
252 Ok(Value::Object(outer))
253 }
254 FilterDialect::Generic => {
255 if fc.op != GraphqlOp::Eq {
256 return Err(error!(
257 "Generic dialect supports only equality; got non-eq operator",
258 field = fc.field.clone(),
259 op = format!("{:?}", fc.op)
260 ));
261 }
262 let mut m = Map::new();
263 m.insert(fc.field.clone(), fc.value.clone());
264 Ok(Value::Object(m))
265 }
266 }
267}
268
269fn combine_and(parts: Vec<Value>, dialect: FilterDialect) -> Result<Value> {
271 match dialect {
272 FilterDialect::Hasura => {
273 let mut merged = Map::new();
276 let mut collision = false;
277 for p in &parts {
278 if let Value::Object(obj) = p {
279 for k in obj.keys() {
280 if merged.contains_key(k) {
281 collision = true;
282 break;
283 }
284 }
285 if collision {
286 break;
287 }
288 if let Value::Object(obj) = p.clone() {
289 for (k, v) in obj {
290 merged.insert(k, v);
291 }
292 }
293 }
294 }
295 if collision {
296 Ok(Value::Object({
297 let mut m = Map::new();
298 m.insert("_and".into(), Value::Array(parts));
299 m
300 }))
301 } else {
302 Ok(Value::Object(merged))
303 }
304 }
305 FilterDialect::Generic => {
306 let mut merged = Map::new();
311 for p in parts {
312 if let Value::Object(obj) = p {
313 for (k, v) in obj {
314 if merged.contains_key(&k) {
315 return Err(error!(
316 "Generic dialect can't express two conditions on the same field",
317 field = k
318 ));
319 }
320 merged.insert(k, v);
321 }
322 }
323 }
324 Ok(Value::Object(merged))
325 }
326 }
327}
328
329impl From<FieldCondition> for GraphqlCondition {
332 fn from(fc: FieldCondition) -> Self {
333 Self::Field(fc)
334 }
335}
336
337impl Expressive<AnyGraphqlType> for GraphqlCondition {
341 fn expr(&self) -> Expression<AnyGraphqlType> {
342 Expression::new(format!("{:?}", self), vec![])
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use serde_json::json;
350
351 #[tokio::test]
352 async fn hasura_renders_eq_as_underscore_eq() {
353 let c = GraphqlCondition::Field(FieldCondition::new(
354 "mission_name",
355 GraphqlOp::Eq,
356 json!("FalconSat"),
357 ));
358 let r = c.render(FilterDialect::Hasura).await.unwrap();
359 assert_eq!(r, json!({ "mission_name": { "_eq": "FalconSat" } }));
360 }
361
362 #[tokio::test]
363 async fn generic_renders_eq_as_flat_field() {
364 let c = GraphqlCondition::Field(FieldCondition::new(
365 "mission_name",
366 GraphqlOp::Eq,
367 json!("FalconSat"),
368 ));
369 let r = c.render(FilterDialect::Generic).await.unwrap();
370 assert_eq!(r, json!({ "mission_name": "FalconSat" }));
371 }
372
373 #[tokio::test]
374 async fn generic_rejects_non_eq() {
375 let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
376 let err = c.render(FilterDialect::Generic).await.unwrap_err();
377 assert!(err.to_string().contains("equality"));
378 }
379
380 #[tokio::test]
381 async fn hasura_renders_gt() {
382 let c = GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(100)));
383 let r = c.render(FilterDialect::Hasura).await.unwrap();
384 assert_eq!(r, json!({ "price": { "_gt": 100 } }));
385 }
386
387 #[tokio::test]
388 async fn hasura_renders_is_null_with_bool_arg() {
389 let c = GraphqlCondition::Field(FieldCondition::new(
390 "deleted_at",
391 GraphqlOp::IsNull,
392 Value::Null,
393 ));
394 let r = c.render(FilterDialect::Hasura).await.unwrap();
395 assert_eq!(r, json!({ "deleted_at": { "_is_null": true } }));
396 }
397
398 #[tokio::test]
399 async fn hasura_and_with_distinct_fields_merges_flat() {
400 let c = GraphqlCondition::And(vec![
401 GraphqlCondition::Field(FieldCondition::new("name", GraphqlOp::Eq, json!("Alice"))),
402 GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
403 ]);
404 let r = c.render(FilterDialect::Hasura).await.unwrap();
405 assert_eq!(
406 r,
407 json!({ "name": { "_eq": "Alice" }, "active": { "_eq": true } })
408 );
409 }
410
411 #[tokio::test]
412 async fn hasura_and_with_same_field_uses_explicit_and() {
413 let c = GraphqlCondition::And(vec![
414 GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Gt, json!(10))),
415 GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Lt, json!(100))),
416 ]);
417 let r = c.render(FilterDialect::Hasura).await.unwrap();
418 assert_eq!(
419 r,
420 json!({
421 "_and": [
422 { "price": { "_gt": 10 } },
423 { "price": { "_lt": 100 } }
424 ]
425 })
426 );
427 }
428
429 #[tokio::test]
430 async fn generic_and_with_same_field_errors() {
431 let c = GraphqlCondition::And(vec![
432 GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(10))),
433 GraphqlCondition::Field(FieldCondition::new("price", GraphqlOp::Eq, json!(20))),
434 ]);
435 let err = c.render(FilterDialect::Generic).await.unwrap_err();
436 assert!(err.to_string().contains("same field"));
437 }
438
439 #[tokio::test]
440 async fn hasura_or_and_not() {
441 let c = GraphqlCondition::Not(Box::new(GraphqlCondition::Or(vec![
442 GraphqlCondition::Field(FieldCondition::new("active", GraphqlOp::Eq, json!(true))),
443 GraphqlCondition::Field(FieldCondition::new("count", GraphqlOp::Gt, json!(0))),
444 ])));
445 let r = c.render(FilterDialect::Hasura).await.unwrap();
446 assert_eq!(
447 r,
448 json!({
449 "_not": {
450 "_or": [
451 { "active": { "_eq": true } },
452 { "count": { "_gt": 0 } }
453 ]
454 }
455 })
456 );
457 }
458
459 #[tokio::test]
460 async fn generic_rejects_or() {
461 let c = GraphqlCondition::Or(vec![
462 GraphqlCondition::Field(FieldCondition::new("a", GraphqlOp::Eq, json!(1))),
463 GraphqlCondition::Field(FieldCondition::new("b", GraphqlOp::Eq, json!(2))),
464 ]);
465 let err = c.render(FilterDialect::Generic).await.unwrap_err();
466 assert!(err.to_string().contains("OR"));
467 }
468}