Skip to main content

supabase_client_graphql/
filter.rs

1use serde_json::{json, Value};
2
3/// Filter operators supported by pg_graphql.
4#[derive(Debug, Clone, PartialEq)]
5pub enum FilterOp {
6    Eq,
7    Neq,
8    Gt,
9    Gte,
10    Lt,
11    Lte,
12    In,
13    Is,
14    Like,
15    Ilike,
16    StartsWith,
17}
18
19impl FilterOp {
20    /// The pg_graphql filter key name.
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            FilterOp::Eq => "eq",
24            FilterOp::Neq => "neq",
25            FilterOp::Gt => "gt",
26            FilterOp::Gte => "gte",
27            FilterOp::Lt => "lt",
28            FilterOp::Lte => "lte",
29            FilterOp::In => "in",
30            FilterOp::Is => "is",
31            FilterOp::Like => "like",
32            FilterOp::Ilike => "ilike",
33            FilterOp::StartsWith => "startsWith",
34        }
35    }
36}
37
38/// Values that can be used with the `is` filter operator.
39#[derive(Debug, Clone, PartialEq)]
40pub enum IsValue {
41    Null,
42    NotNull,
43    True,
44    False,
45}
46
47impl IsValue {
48    /// Convert to the pg_graphql enum literal.
49    pub fn as_graphql_literal(&self) -> &'static str {
50        match self {
51            IsValue::Null => "NULL",
52            IsValue::NotNull => "NOT_NULL",
53            IsValue::True => "TRUE",
54            IsValue::False => "FALSE",
55        }
56    }
57}
58
59/// A filter expression for GraphQL queries.
60///
61/// Maps to pg_graphql's filter argument structure.
62///
63/// # Examples
64///
65/// ```
66/// use supabase_client_graphql::GqlFilter;
67///
68/// // Simple equality: { "title": { "eq": "hello" } }
69/// let filter = GqlFilter::eq("title", "hello");
70///
71/// // Compound: { "and": [{ "age": { "gte": 18 } }, { "status": { "eq": "active" } }] }
72/// let filter = GqlFilter::and(vec![
73///     GqlFilter::gte("age", 18),
74///     GqlFilter::eq("status", "active"),
75/// ]);
76/// ```
77#[derive(Debug, Clone)]
78pub enum GqlFilter {
79    /// A field-level comparison: `{ column: { op: value } }`.
80    Field {
81        column: String,
82        op: FilterOp,
83        value: Value,
84    },
85    /// An `is` filter for null/boolean checks: `{ column: { is: NULL } }`.
86    Is {
87        column: String,
88        value: IsValue,
89    },
90    /// Logical AND of multiple filters.
91    And(Vec<GqlFilter>),
92    /// Logical OR of multiple filters.
93    Or(Vec<GqlFilter>),
94    /// Logical NOT of a filter.
95    Not(Box<GqlFilter>),
96}
97
98impl GqlFilter {
99    /// Create an equality filter: `{ column: { eq: value } }`.
100    pub fn eq(column: impl Into<String>, value: impl Into<Value>) -> Self {
101        GqlFilter::Field {
102            column: column.into(),
103            op: FilterOp::Eq,
104            value: value.into(),
105        }
106    }
107
108    /// Create a not-equal filter: `{ column: { neq: value } }`.
109    pub fn neq(column: impl Into<String>, value: impl Into<Value>) -> Self {
110        GqlFilter::Field {
111            column: column.into(),
112            op: FilterOp::Neq,
113            value: value.into(),
114        }
115    }
116
117    /// Create a greater-than filter: `{ column: { gt: value } }`.
118    pub fn gt(column: impl Into<String>, value: impl Into<Value>) -> Self {
119        GqlFilter::Field {
120            column: column.into(),
121            op: FilterOp::Gt,
122            value: value.into(),
123        }
124    }
125
126    /// Create a greater-than-or-equal filter: `{ column: { gte: value } }`.
127    pub fn gte(column: impl Into<String>, value: impl Into<Value>) -> Self {
128        GqlFilter::Field {
129            column: column.into(),
130            op: FilterOp::Gte,
131            value: value.into(),
132        }
133    }
134
135    /// Create a less-than filter: `{ column: { lt: value } }`.
136    pub fn lt(column: impl Into<String>, value: impl Into<Value>) -> Self {
137        GqlFilter::Field {
138            column: column.into(),
139            op: FilterOp::Lt,
140            value: value.into(),
141        }
142    }
143
144    /// Create a less-than-or-equal filter: `{ column: { lte: value } }`.
145    pub fn lte(column: impl Into<String>, value: impl Into<Value>) -> Self {
146        GqlFilter::Field {
147            column: column.into(),
148            op: FilterOp::Lte,
149            value: value.into(),
150        }
151    }
152
153    /// Create an `in` filter: `{ column: { in: [values] } }`.
154    pub fn in_(column: impl Into<String>, values: Vec<Value>) -> Self {
155        GqlFilter::Field {
156            column: column.into(),
157            op: FilterOp::In,
158            value: Value::Array(values),
159        }
160    }
161
162    /// Create an `is null` filter: `{ column: { is: NULL } }`.
163    pub fn is_null(column: impl Into<String>) -> Self {
164        GqlFilter::Is {
165            column: column.into(),
166            value: IsValue::Null,
167        }
168    }
169
170    /// Create an `is not null` filter: `{ column: { is: NOT_NULL } }`.
171    pub fn is_not_null(column: impl Into<String>) -> Self {
172        GqlFilter::Is {
173            column: column.into(),
174            value: IsValue::NotNull,
175        }
176    }
177
178    /// Create a `like` filter: `{ column: { like: pattern } }`.
179    pub fn like(column: impl Into<String>, pattern: impl Into<String>) -> Self {
180        GqlFilter::Field {
181            column: column.into(),
182            op: FilterOp::Like,
183            value: Value::String(pattern.into()),
184        }
185    }
186
187    /// Create an `ilike` (case-insensitive like) filter.
188    pub fn ilike(column: impl Into<String>, pattern: impl Into<String>) -> Self {
189        GqlFilter::Field {
190            column: column.into(),
191            op: FilterOp::Ilike,
192            value: Value::String(pattern.into()),
193        }
194    }
195
196    /// Create a `startsWith` filter.
197    pub fn starts_with(column: impl Into<String>, prefix: impl Into<String>) -> Self {
198        GqlFilter::Field {
199            column: column.into(),
200            op: FilterOp::StartsWith,
201            value: Value::String(prefix.into()),
202        }
203    }
204
205    /// Logical AND of multiple filters.
206    pub fn and(filters: Vec<GqlFilter>) -> Self {
207        GqlFilter::And(filters)
208    }
209
210    /// Logical OR of multiple filters.
211    pub fn or(filters: Vec<GqlFilter>) -> Self {
212        GqlFilter::Or(filters)
213    }
214
215    /// Logical NOT of a filter.
216    pub fn not(filter: GqlFilter) -> Self {
217        GqlFilter::Not(Box::new(filter))
218    }
219
220    /// Convert this filter to a `serde_json::Value` for inlining into the query.
221    pub fn to_value(&self) -> Value {
222        match self {
223            GqlFilter::Field { column, op, value } => {
224                json!({ column.as_str(): { op.as_str(): value } })
225            }
226            GqlFilter::Is { column, value } => {
227                // Is values are GraphQL enums — they'll be rendered as unquoted literals
228                // by the render module. Store them as strings here.
229                json!({ column.as_str(): { "is": value.as_graphql_literal() } })
230            }
231            GqlFilter::And(filters) => {
232                let arr: Vec<Value> = filters.iter().map(|f| f.to_value()).collect();
233                json!({ "and": arr })
234            }
235            GqlFilter::Or(filters) => {
236                let arr: Vec<Value> = filters.iter().map(|f| f.to_value()).collect();
237                json!({ "or": arr })
238            }
239            GqlFilter::Not(filter) => {
240                json!({ "not": filter.to_value() })
241            }
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn filter_eq_string() {
252        let f = GqlFilter::eq("title", json!("hello"));
253        let v = f.to_value();
254        assert_eq!(v, json!({"title": {"eq": "hello"}}));
255    }
256
257    #[test]
258    fn filter_eq_number() {
259        let f = GqlFilter::eq("age", json!(25));
260        let v = f.to_value();
261        assert_eq!(v, json!({"age": {"eq": 25}}));
262    }
263
264    #[test]
265    fn filter_neq() {
266        let f = GqlFilter::neq("status", json!("draft"));
267        let v = f.to_value();
268        assert_eq!(v, json!({"status": {"neq": "draft"}}));
269    }
270
271    #[test]
272    fn filter_gt_gte_lt_lte() {
273        assert_eq!(
274            GqlFilter::gt("x", json!(10)).to_value(),
275            json!({"x": {"gt": 10}})
276        );
277        assert_eq!(
278            GqlFilter::gte("x", json!(10)).to_value(),
279            json!({"x": {"gte": 10}})
280        );
281        assert_eq!(
282            GqlFilter::lt("x", json!(10)).to_value(),
283            json!({"x": {"lt": 10}})
284        );
285        assert_eq!(
286            GqlFilter::lte("x", json!(10)).to_value(),
287            json!({"x": {"lte": 10}})
288        );
289    }
290
291    #[test]
292    fn filter_in() {
293        let f = GqlFilter::in_("id", vec![json!(1), json!(2), json!(3)]);
294        let v = f.to_value();
295        assert_eq!(v, json!({"id": {"in": [1, 2, 3]}}));
296    }
297
298    #[test]
299    fn filter_is_null() {
300        let f = GqlFilter::is_null("deleted_at");
301        let v = f.to_value();
302        assert_eq!(v, json!({"deleted_at": {"is": "NULL"}}));
303    }
304
305    #[test]
306    fn filter_is_not_null() {
307        let f = GqlFilter::is_not_null("email");
308        let v = f.to_value();
309        assert_eq!(v, json!({"email": {"is": "NOT_NULL"}}));
310    }
311
312    #[test]
313    fn filter_like() {
314        let f = GqlFilter::like("name", "%test%");
315        let v = f.to_value();
316        assert_eq!(v, json!({"name": {"like": "%test%"}}));
317    }
318
319    #[test]
320    fn filter_ilike() {
321        let f = GqlFilter::ilike("name", "%TEST%");
322        let v = f.to_value();
323        assert_eq!(v, json!({"name": {"ilike": "%TEST%"}}));
324    }
325
326    #[test]
327    fn filter_starts_with() {
328        let f = GqlFilter::starts_with("name", "foo");
329        let v = f.to_value();
330        assert_eq!(v, json!({"name": {"startsWith": "foo"}}));
331    }
332
333    #[test]
334    fn filter_and() {
335        let f = GqlFilter::and(vec![
336            GqlFilter::gte("age", json!(18)),
337            GqlFilter::eq("status", json!("active")),
338        ]);
339        let v = f.to_value();
340        assert_eq!(
341            v,
342            json!({"and": [{"age": {"gte": 18}}, {"status": {"eq": "active"}}]})
343        );
344    }
345
346    #[test]
347    fn filter_or() {
348        let f = GqlFilter::or(vec![
349            GqlFilter::eq("role", json!("admin")),
350            GqlFilter::eq("role", json!("moderator")),
351        ]);
352        let v = f.to_value();
353        assert_eq!(
354            v,
355            json!({"or": [{"role": {"eq": "admin"}}, {"role": {"eq": "moderator"}}]})
356        );
357    }
358
359    #[test]
360    fn filter_not() {
361        let f = GqlFilter::not(GqlFilter::eq("deleted", json!(true)));
362        let v = f.to_value();
363        assert_eq!(v, json!({"not": {"deleted": {"eq": true}}}));
364    }
365
366    #[test]
367    fn filter_nested_compound() {
368        let f = GqlFilter::and(vec![
369            GqlFilter::eq("active", json!(true)),
370            GqlFilter::or(vec![
371                GqlFilter::eq("role", json!("admin")),
372                GqlFilter::gte("level", json!(5)),
373            ]),
374        ]);
375        let v = f.to_value();
376        assert_eq!(
377            v,
378            json!({
379                "and": [
380                    {"active": {"eq": true}},
381                    {"or": [
382                        {"role": {"eq": "admin"}},
383                        {"level": {"gte": 5}}
384                    ]}
385                ]
386            })
387        );
388    }
389}