Skip to main content

vantage_api_client/graphql/
operation.rs

1//! `GraphqlOperation` — typed comparison/logical operators that produce
2//! `GraphqlCondition`. Blanket-implemented over all `Expressive<T>`, so
3//! typed columns get `.eq()`/`.gt()`/`.in_()`/… for free.
4//!
5//! ```ignore
6//! use vantage_api_client::graphql::operation::GraphqlOperation;
7//! use vantage_table::column::core::Column;
8//!
9//! let mission = Column::<String>::new("mission_name");
10//! let cond = mission.eq("FalconSat");
11//! // GraphqlCondition::Field { field: "mission_name", op: Eq, value: "FalconSat" }
12//! ```
13//!
14//! The same pattern as `vantage-mongodb`'s `MongoOperation`. Field name
15//! is pulled from `self.expr().template`, which works for typed
16//! `Column<T>` (the column name comes out verbatim). Complex
17//! expressions land their rendered template as the field, which is
18//! rarely what you want — keep operands simple.
19
20use serde_json::Value;
21use vantage_expressions::Expressive;
22
23use crate::graphql::condition::{FieldCondition, GraphqlCondition, GraphqlOp};
24use crate::graphql::types::{AnyGraphqlType, GraphqlType};
25
26fn field_name<T>(expr: &(impl Expressive<T> + ?Sized)) -> String {
27    expr.expr().template.clone()
28}
29
30fn to_json(value: impl Into<AnyGraphqlType>) -> Value {
31    value.into().to_json()
32}
33
34fn to_json_array<I, V>(values: I) -> Value
35where
36    I: IntoIterator<Item = V>,
37    V: Into<AnyGraphqlType>,
38{
39    Value::Array(values.into_iter().map(to_json).collect())
40}
41
42/// GraphQL operators that produce a [`GraphqlCondition`].
43///
44/// Blanket-impl'd for any `Expressive<T>`. Import this trait alongside
45/// your column types when writing GraphQL filters; don't mix it with
46/// `vantage_table::operation::Operation` in the same scope (which would
47/// shadow these methods with raw-expression versions).
48pub trait GraphqlOperation<T>: Expressive<T> {
49    /// `field = value`
50    fn eq(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
51    where
52        Self: Sized,
53    {
54        GraphqlCondition::Field(FieldCondition::new(
55            field_name(self),
56            GraphqlOp::Eq,
57            to_json(value),
58        ))
59    }
60
61    /// `field != value`
62    fn ne(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
63    where
64        Self: Sized,
65    {
66        GraphqlCondition::Field(FieldCondition::new(
67            field_name(self),
68            GraphqlOp::Ne,
69            to_json(value),
70        ))
71    }
72
73    /// `field > value`
74    fn gt(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
75    where
76        Self: Sized,
77    {
78        GraphqlCondition::Field(FieldCondition::new(
79            field_name(self),
80            GraphqlOp::Gt,
81            to_json(value),
82        ))
83    }
84
85    /// `field >= value`
86    fn gte(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
87    where
88        Self: Sized,
89    {
90        GraphqlCondition::Field(FieldCondition::new(
91            field_name(self),
92            GraphqlOp::Gte,
93            to_json(value),
94        ))
95    }
96
97    /// `field < value`
98    fn lt(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
99    where
100        Self: Sized,
101    {
102        GraphqlCondition::Field(FieldCondition::new(
103            field_name(self),
104            GraphqlOp::Lt,
105            to_json(value),
106        ))
107    }
108
109    /// `field <= value`
110    fn lte(&self, value: impl Into<AnyGraphqlType>) -> GraphqlCondition
111    where
112        Self: Sized,
113    {
114        GraphqlCondition::Field(FieldCondition::new(
115            field_name(self),
116            GraphqlOp::Lte,
117            to_json(value),
118        ))
119    }
120
121    /// `field IN [values...]`
122    fn in_<I, V>(&self, values: I) -> GraphqlCondition
123    where
124        Self: Sized,
125        I: IntoIterator<Item = V>,
126        V: Into<AnyGraphqlType>,
127    {
128        GraphqlCondition::Field(FieldCondition::new(
129            field_name(self),
130            GraphqlOp::In,
131            to_json_array(values),
132        ))
133    }
134
135    /// `field NOT IN [values...]`
136    fn not_in<I, V>(&self, values: I) -> GraphqlCondition
137    where
138        Self: Sized,
139        I: IntoIterator<Item = V>,
140        V: Into<AnyGraphqlType>,
141    {
142        GraphqlCondition::Field(FieldCondition::new(
143            field_name(self),
144            GraphqlOp::NotIn,
145            to_json_array(values),
146        ))
147    }
148
149    /// `field LIKE pattern` — case-sensitive substring match.
150    fn like(&self, pattern: impl Into<String>) -> GraphqlCondition
151    where
152        Self: Sized,
153    {
154        GraphqlCondition::Field(FieldCondition::new(
155            field_name(self),
156            GraphqlOp::Like,
157            Value::String(pattern.into()),
158        ))
159    }
160
161    /// `field ILIKE pattern` — case-insensitive substring match.
162    fn ilike(&self, pattern: impl Into<String>) -> GraphqlCondition
163    where
164        Self: Sized,
165    {
166        GraphqlCondition::Field(FieldCondition::new(
167            field_name(self),
168            GraphqlOp::ILike,
169            Value::String(pattern.into()),
170        ))
171    }
172
173    /// `field IS NULL`
174    fn is_null(&self) -> GraphqlCondition
175    where
176        Self: Sized,
177    {
178        GraphqlCondition::Field(FieldCondition::new(
179            field_name(self),
180            GraphqlOp::IsNull,
181            Value::Null,
182        ))
183    }
184
185    /// `field IS NOT NULL`
186    fn is_not_null(&self) -> GraphqlCondition
187    where
188        Self: Sized,
189    {
190        GraphqlCondition::Field(FieldCondition::new(
191            field_name(self),
192            GraphqlOp::IsNotNull,
193            Value::Null,
194        ))
195    }
196}
197
198/// Blanket: any `Expressive<T>` gets `GraphqlOperation<T>` for free.
199impl<T, S: Expressive<T>> GraphqlOperation<T> for S {}
200
201// Tip the type checker about the unused import in builds that don't
202// touch the chrono module — `to_json` only needs the `GraphqlType` trait
203// in scope through this path.
204#[allow(dead_code)]
205fn _assert_graphql_type_in_scope<T: GraphqlType>(_: &T) {}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::graphql::condition::FilterDialect;
211    use serde_json::json;
212    use vantage_table::column::core::Column;
213
214    #[tokio::test]
215    async fn column_eq_renders_hasura() {
216        let mission = Column::<String>::new("mission_name");
217        let cond = mission.eq("FalconSat");
218        let r = cond.render(FilterDialect::Hasura).await.unwrap();
219        assert_eq!(r, json!({ "mission_name": { "_eq": "FalconSat" } }));
220    }
221
222    #[tokio::test]
223    async fn column_eq_renders_generic() {
224        let mission = Column::<String>::new("mission_name");
225        let cond = mission.eq("FalconSat");
226        let r = cond.render(FilterDialect::Generic).await.unwrap();
227        assert_eq!(r, json!({ "mission_name": "FalconSat" }));
228    }
229
230    #[tokio::test]
231    async fn column_gt_renders_hasura() {
232        let price = Column::<i64>::new("price");
233        let cond = price.gt(100i64);
234        let r = cond.render(FilterDialect::Hasura).await.unwrap();
235        assert_eq!(r, json!({ "price": { "_gt": 100 } }));
236    }
237
238    #[tokio::test]
239    async fn column_in_renders_hasura_array() {
240        let status = Column::<String>::new("status");
241        let cond = status.in_(vec!["active", "pending"]);
242        let r = cond.render(FilterDialect::Hasura).await.unwrap();
243        assert_eq!(r, json!({ "status": { "_in": ["active", "pending"] } }));
244    }
245
246    #[tokio::test]
247    async fn column_is_null_renders_hasura_bool() {
248        let deleted = Column::<String>::new("deleted_at");
249        let cond = deleted.is_null();
250        let r = cond.render(FilterDialect::Hasura).await.unwrap();
251        assert_eq!(r, json!({ "deleted_at": { "_is_null": true } }));
252    }
253
254    #[tokio::test]
255    async fn column_ilike_renders_hasura() {
256        let name = Column::<String>::new("name");
257        let cond = name.ilike("%falcon%");
258        let r = cond.render(FilterDialect::Hasura).await.unwrap();
259        assert_eq!(r, json!({ "name": { "_ilike": "%falcon%" } }));
260    }
261}