Skip to main content

icydb/db/query/
expr.rs

1use crate::{
2    traits::{EntityKind, FieldValue},
3    value::Value,
4};
5use candid::CandidType;
6use icydb_core::db::{
7    CoercionId, CompareOp, ComparePredicate, FilterExpr as CoreFilterExpr,
8    OrderDirection as CoreOrderDirection, Predicate, QueryError, SortExpr as CoreSortExpr,
9};
10use serde::Deserialize;
11
12//
13// FilterExpr
14//
15// Serialized, planner-agnostic predicate language.
16//
17// This enum is intentionally isomorphic to the subset of core::Predicate that is:
18// - deterministic
19// - schema-visible
20// - safe across API boundaries
21//
22// No planner hints, no implicit semantics, no overloaded operators.
23// Any new Predicate variant must be explicitly reviewed for exposure here.
24//
25
26#[derive(CandidType, Clone, Debug, Deserialize)]
27#[serde(rename_all = "PascalCase")]
28pub enum FilterExpr {
29    /// Always true.
30    True,
31    /// Always false.
32    False,
33
34    And(Vec<Self>),
35    Or(Vec<Self>),
36    Not(Box<Self>),
37
38    // ─────────────────────────────────────────────────────────────
39    // Scalar comparisons
40    // ─────────────────────────────────────────────────────────────
41    Eq {
42        field: String,
43        value: Value,
44    },
45    Ne {
46        field: String,
47        value: Value,
48    },
49    Lt {
50        field: String,
51        value: Value,
52    },
53    Lte {
54        field: String,
55        value: Value,
56    },
57    Gt {
58        field: String,
59        value: Value,
60    },
61    Gte {
62        field: String,
63        value: Value,
64    },
65
66    In {
67        field: String,
68        values: Vec<Value>,
69    },
70    NotIn {
71        field: String,
72        values: Vec<Value>,
73    },
74
75    // ─────────────────────────────────────────────────────────────
76    // Collection predicates
77    // ─────────────────────────────────────────────────────────────
78    /// Collection contains value.
79    Contains {
80        field: String,
81        value: Value,
82    },
83
84    // ─────────────────────────────────────────────────────────────
85    // Text predicates (explicit, no overloading)
86    // ─────────────────────────────────────────────────────────────
87    /// Case-sensitive substring match.
88    TextContains {
89        field: String,
90        value: Value,
91    },
92
93    /// Case-insensitive substring match.
94    TextContainsCi {
95        field: String,
96        value: Value,
97    },
98
99    StartsWith {
100        field: String,
101        value: Value,
102    },
103    StartsWithCi {
104        field: String,
105        value: Value,
106    },
107
108    EndsWith {
109        field: String,
110        value: Value,
111    },
112    EndsWithCi {
113        field: String,
114        value: Value,
115    },
116
117    // ─────────────────────────────────────────────────────────────
118    // Presence / nullability
119    // ─────────────────────────────────────────────────────────────
120    /// Field is present and explicitly null.
121    IsNull {
122        field: String,
123    },
124
125    /// Field is present and not null.
126    /// Equivalent to: NOT IsNull AND NOT IsMissing
127    IsNotNull {
128        field: String,
129    },
130
131    /// Field is not present at all.
132    IsMissing {
133        field: String,
134    },
135
136    /// Field is present but empty (collection or string).
137    IsEmpty {
138        field: String,
139    },
140
141    /// Field is present and non-empty.
142    IsNotEmpty {
143        field: String,
144    },
145}
146
147impl FilterExpr {
148    // ─────────────────────────────────────────────────────────────
149    // Lowering
150    // ─────────────────────────────────────────────────────────────
151
152    /// Lower this API-level filter expression into core predicate IR.
153    ///
154    /// Lowering applies explicit coercion policies so execution semantics are stable.
155    #[expect(clippy::too_many_lines)]
156    pub fn lower<E: EntityKind>(&self) -> Result<CoreFilterExpr, QueryError> {
157        let lower_pred =
158            |expr: &Self| -> Result<Predicate, QueryError> { Ok(expr.lower::<E>()?.0) };
159
160        let pred = match self {
161            Self::True => Predicate::True,
162            Self::False => Predicate::False,
163
164            Self::And(xs) => {
165                Predicate::and(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
166            }
167            Self::Or(xs) => {
168                Predicate::or(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
169            }
170            Self::Not(x) => Predicate::not(lower_pred(x)?),
171
172            Self::Eq { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
173                field.as_str(),
174                CompareOp::Eq,
175                value.clone(),
176                CoercionId::Strict,
177            )),
178
179            Self::Ne { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
180                field.as_str(),
181                CompareOp::Ne,
182                value.clone(),
183                CoercionId::Strict,
184            )),
185
186            Self::Lt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
187                field.as_str(),
188                CompareOp::Lt,
189                value.clone(),
190                CoercionId::NumericWiden,
191            )),
192
193            Self::Lte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
194                field.as_str(),
195                CompareOp::Lte,
196                value.clone(),
197                CoercionId::NumericWiden,
198            )),
199
200            Self::Gt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
201                field.as_str(),
202                CompareOp::Gt,
203                value.clone(),
204                CoercionId::NumericWiden,
205            )),
206
207            Self::Gte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
208                field.as_str(),
209                CompareOp::Gte,
210                value.clone(),
211                CoercionId::NumericWiden,
212            )),
213
214            Self::In { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
215                field.as_str(),
216                CompareOp::In,
217                Value::List(values.clone()),
218                CoercionId::Strict,
219            )),
220
221            Self::NotIn { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
222                field.as_str(),
223                CompareOp::NotIn,
224                Value::List(values.clone()),
225                CoercionId::Strict,
226            )),
227
228            Self::Contains { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
229                field.as_str(),
230                CompareOp::Contains,
231                value.clone(),
232                CoercionId::Strict,
233            )),
234
235            Self::TextContains { field, value } => Predicate::TextContains {
236                field: field.clone(),
237                value: value.clone(),
238            },
239
240            Self::TextContainsCi { field, value } => Predicate::TextContainsCi {
241                field: field.clone(),
242                value: value.clone(),
243            },
244
245            Self::StartsWith { field, value } => {
246                Predicate::Compare(ComparePredicate::with_coercion(
247                    field.as_str(),
248                    CompareOp::StartsWith,
249                    value.clone(),
250                    CoercionId::Strict,
251                ))
252            }
253
254            Self::StartsWithCi { field, value } => {
255                Predicate::Compare(ComparePredicate::with_coercion(
256                    field.as_str(),
257                    CompareOp::StartsWith,
258                    value.clone(),
259                    CoercionId::TextCasefold,
260                ))
261            }
262
263            Self::EndsWith { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
264                field.as_str(),
265                CompareOp::EndsWith,
266                value.clone(),
267                CoercionId::Strict,
268            )),
269
270            Self::EndsWithCi { field, value } => {
271                Predicate::Compare(ComparePredicate::with_coercion(
272                    field.as_str(),
273                    CompareOp::EndsWith,
274                    value.clone(),
275                    CoercionId::TextCasefold,
276                ))
277            }
278
279            Self::IsNull { field } => Predicate::IsNull {
280                field: field.clone(),
281            },
282
283            Self::IsNotNull { field } => Predicate::and(vec![
284                Predicate::not(Predicate::IsNull {
285                    field: field.clone(),
286                }),
287                Predicate::not(Predicate::IsMissing {
288                    field: field.clone(),
289                }),
290            ]),
291
292            Self::IsMissing { field } => Predicate::IsMissing {
293                field: field.clone(),
294            },
295
296            Self::IsEmpty { field } => Predicate::IsEmpty {
297                field: field.clone(),
298            },
299
300            Self::IsNotEmpty { field } => Predicate::IsNotEmpty {
301                field: field.clone(),
302            },
303        };
304
305        Ok(CoreFilterExpr(pred))
306    }
307
308    // ─────────────────────────────────────────────────────────────
309    // Boolean
310    // ─────────────────────────────────────────────────────────────
311
312    /// Build an `And` expression from a list of child expressions.
313    #[must_use]
314    pub const fn and(exprs: Vec<Self>) -> Self {
315        Self::And(exprs)
316    }
317
318    /// Build an `Or` expression from a list of child expressions.
319    #[must_use]
320    pub const fn or(exprs: Vec<Self>) -> Self {
321        Self::Or(exprs)
322    }
323
324    /// Negate one child expression.
325    #[must_use]
326    #[expect(clippy::should_implement_trait)]
327    pub fn not(expr: Self) -> Self {
328        Self::Not(Box::new(expr))
329    }
330
331    // ─────────────────────────────────────────────────────────────
332    // Scalar comparisons
333    // ─────────────────────────────────────────────────────────────
334
335    /// Compare `field == value`.
336    pub fn eq(field: impl Into<String>, value: impl FieldValue) -> Self {
337        Self::Eq {
338            field: field.into(),
339            value: value.to_value(),
340        }
341    }
342
343    /// Compare `field != value`.
344    pub fn ne(field: impl Into<String>, value: impl FieldValue) -> Self {
345        Self::Ne {
346            field: field.into(),
347            value: value.to_value(),
348        }
349    }
350
351    /// Compare `field < value`.
352    pub fn lt(field: impl Into<String>, value: impl FieldValue) -> Self {
353        Self::Lt {
354            field: field.into(),
355            value: value.to_value(),
356        }
357    }
358
359    /// Compare `field <= value`.
360    pub fn lte(field: impl Into<String>, value: impl FieldValue) -> Self {
361        Self::Lte {
362            field: field.into(),
363            value: value.to_value(),
364        }
365    }
366
367    /// Compare `field > value`.
368    pub fn gt(field: impl Into<String>, value: impl FieldValue) -> Self {
369        Self::Gt {
370            field: field.into(),
371            value: value.to_value(),
372        }
373    }
374
375    /// Compare `field >= value`.
376    pub fn gte(field: impl Into<String>, value: impl FieldValue) -> Self {
377        Self::Gte {
378            field: field.into(),
379            value: value.to_value(),
380        }
381    }
382
383    /// Compare `field IN values`.
384    pub fn in_list(
385        field: impl Into<String>,
386        values: impl IntoIterator<Item = impl FieldValue>,
387    ) -> Self {
388        Self::In {
389            field: field.into(),
390            values: values.into_iter().map(|v| v.to_value()).collect(),
391        }
392    }
393
394    /// Compare `field NOT IN values`.
395    pub fn not_in(
396        field: impl Into<String>,
397        values: impl IntoIterator<Item = impl FieldValue>,
398    ) -> Self {
399        Self::NotIn {
400            field: field.into(),
401            values: values.into_iter().map(|v| v.to_value()).collect(),
402        }
403    }
404
405    // ─────────────────────────────────────────────────────────────
406    // Collection
407    // ─────────────────────────────────────────────────────────────
408
409    /// Compare collection `field CONTAINS value`.
410    pub fn contains(field: impl Into<String>, value: impl FieldValue) -> Self {
411        Self::Contains {
412            field: field.into(),
413            value: value.to_value(),
414        }
415    }
416
417    // ─────────────────────────────────────────────────────────────
418    // Text predicates
419    // ─────────────────────────────────────────────────────────────
420
421    /// Compare case-sensitive substring containment.
422    pub fn text_contains(field: impl Into<String>, value: impl FieldValue) -> Self {
423        Self::TextContains {
424            field: field.into(),
425            value: value.to_value(),
426        }
427    }
428
429    /// Compare case-insensitive substring containment.
430    pub fn text_contains_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
431        Self::TextContainsCi {
432            field: field.into(),
433            value: value.to_value(),
434        }
435    }
436
437    /// Compare case-sensitive prefix match.
438    pub fn starts_with(field: impl Into<String>, value: impl FieldValue) -> Self {
439        Self::StartsWith {
440            field: field.into(),
441            value: value.to_value(),
442        }
443    }
444
445    /// Compare case-insensitive prefix match.
446    pub fn starts_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
447        Self::StartsWithCi {
448            field: field.into(),
449            value: value.to_value(),
450        }
451    }
452
453    /// Compare case-sensitive suffix match.
454    pub fn ends_with(field: impl Into<String>, value: impl FieldValue) -> Self {
455        Self::EndsWith {
456            field: field.into(),
457            value: value.to_value(),
458        }
459    }
460
461    /// Compare case-insensitive suffix match.
462    pub fn ends_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
463        Self::EndsWithCi {
464            field: field.into(),
465            value: value.to_value(),
466        }
467    }
468
469    // ─────────────────────────────────────────────────────────────
470    // Presence / nullability
471    // ─────────────────────────────────────────────────────────────
472
473    /// Match rows where `field` is present and null.
474    pub fn is_null(field: impl Into<String>) -> Self {
475        Self::IsNull {
476            field: field.into(),
477        }
478    }
479
480    /// Match rows where `field` is present and non-null.
481    pub fn is_not_null(field: impl Into<String>) -> Self {
482        Self::IsNotNull {
483            field: field.into(),
484        }
485    }
486
487    /// Match rows where `field` is absent.
488    pub fn is_missing(field: impl Into<String>) -> Self {
489        Self::IsMissing {
490            field: field.into(),
491        }
492    }
493
494    /// Match rows where `field` is present and empty.
495    pub fn is_empty(field: impl Into<String>) -> Self {
496        Self::IsEmpty {
497            field: field.into(),
498        }
499    }
500
501    /// Match rows where `field` is present and non-empty.
502    pub fn is_not_empty(field: impl Into<String>) -> Self {
503        Self::IsNotEmpty {
504            field: field.into(),
505        }
506    }
507}
508
509//
510// SortExpr
511//
512
513#[derive(CandidType, Clone, Debug, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub struct SortExpr {
516    fields: Vec<(String, OrderDirection)>,
517}
518
519impl SortExpr {
520    /// Build a sort specification from ordered `(field, direction)` pairs.
521    #[must_use]
522    pub const fn new(fields: Vec<(String, OrderDirection)>) -> Self {
523        Self { fields }
524    }
525
526    /// Borrow the ordered sort fields.
527    #[must_use]
528    pub fn fields(&self) -> &[(String, OrderDirection)] {
529        &self.fields
530    }
531
532    /// Lower this API-level sort expression into core sort IR.
533    #[must_use]
534    pub fn lower(&self) -> CoreSortExpr {
535        let fields = self
536            .fields()
537            .iter()
538            .map(|(field, dir)| {
539                let dir = match dir {
540                    OrderDirection::Asc => CoreOrderDirection::Asc,
541                    OrderDirection::Desc => CoreOrderDirection::Desc,
542                };
543                (field.clone(), dir)
544            })
545            .collect();
546
547        CoreSortExpr::new(fields)
548    }
549}
550
551//
552// OrderDirection
553//
554
555#[derive(CandidType, Clone, Copy, Debug, Deserialize)]
556#[serde(rename_all = "PascalCase")]
557pub enum OrderDirection {
558    Asc,
559    Desc,
560}
561
562//
563// TESTS
564//
565
566#[cfg(test)]
567mod tests {
568    use super::{FilterExpr, OrderDirection, SortExpr};
569    use candid::types::{CandidType, Label, Type, TypeInner};
570
571    fn expect_record_fields(ty: Type) -> Vec<String> {
572        match ty.as_ref() {
573            TypeInner::Record(fields) => fields
574                .iter()
575                .map(|field| match field.id.as_ref() {
576                    Label::Named(name) => name.clone(),
577                    other => panic!("expected named record field, got {other:?}"),
578                })
579                .collect(),
580            other => panic!("expected candid record, got {other:?}"),
581        }
582    }
583
584    fn expect_variant_labels(ty: Type) -> Vec<String> {
585        match ty.as_ref() {
586            TypeInner::Variant(fields) => fields
587                .iter()
588                .map(|field| match field.id.as_ref() {
589                    Label::Named(name) => name.clone(),
590                    other => panic!("expected named variant label, got {other:?}"),
591                })
592                .collect(),
593            other => panic!("expected candid variant, got {other:?}"),
594        }
595    }
596
597    fn expect_variant_field_type(ty: Type, variant_name: &str) -> Type {
598        match ty.as_ref() {
599            TypeInner::Variant(fields) => fields
600                .iter()
601                .find_map(|field| match field.id.as_ref() {
602                    Label::Named(name) if name == variant_name => Some(field.ty.clone()),
603                    _ => None,
604                })
605                .unwrap_or_else(|| panic!("expected variant label `{variant_name}`")),
606            other => panic!("expected candid variant, got {other:?}"),
607        }
608    }
609
610    #[test]
611    fn filter_expr_eq_candid_payload_shape_is_stable() {
612        let fields = expect_record_fields(expect_variant_field_type(FilterExpr::ty(), "Eq"));
613
614        for field in ["field", "value"] {
615            assert!(
616                fields.iter().any(|candidate| candidate == field),
617                "Eq payload must keep `{field}` field key in Candid shape",
618            );
619        }
620    }
621
622    #[test]
623    fn filter_expr_and_candid_payload_shape_is_stable() {
624        match expect_variant_field_type(FilterExpr::ty(), "And").as_ref() {
625            TypeInner::Vec(_) => {}
626            other => panic!("And payload must remain a Candid vec payload, got {other:?}"),
627        }
628    }
629
630    #[test]
631    fn sort_expr_candid_field_name_is_stable() {
632        let fields = expect_record_fields(SortExpr::ty());
633
634        assert!(
635            fields.iter().any(|candidate| candidate == "fields"),
636            "SortExpr must keep `fields` as Candid field key",
637        );
638    }
639
640    #[test]
641    fn order_direction_variant_labels_are_stable() {
642        let mut labels = expect_variant_labels(OrderDirection::ty());
643        labels.sort_unstable();
644        assert_eq!(labels, vec!["Asc".to_string(), "Desc".to_string()]);
645    }
646
647    #[test]
648    fn filter_expr_text_contains_ci_candid_payload_shape_is_stable() {
649        let fields = expect_record_fields(expect_variant_field_type(
650            FilterExpr::ty(),
651            "TextContainsCi",
652        ));
653
654        for field in ["field", "value"] {
655            assert!(
656                fields.iter().any(|candidate| candidate == field),
657                "TextContainsCi payload must keep `{field}` field key in Candid shape",
658            );
659        }
660    }
661
662    #[test]
663    fn filter_expr_not_payload_shape_is_stable() {
664        match expect_variant_field_type(FilterExpr::ty(), "Not").as_ref() {
665            TypeInner::Var(_) | TypeInner::Knot(_) | TypeInner::Variant(_) => {}
666            other => panic!("Not payload must keep nested predicate payload, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn filter_expr_variant_labels_are_stable() {
672        let labels = expect_variant_labels(FilterExpr::ty());
673
674        for label in ["Eq", "And", "Not", "TextContainsCi", "IsMissing"] {
675            assert!(
676                labels.iter().any(|candidate| candidate == label),
677                "FilterExpr must keep `{label}` variant label",
678            );
679        }
680    }
681
682    #[test]
683    fn query_expr_fixture_constructors_stay_usable() {
684        let expr = FilterExpr::and(vec![
685            FilterExpr::is_null("deleted_at"),
686            FilterExpr::not(FilterExpr::is_missing("name")),
687        ]);
688        let sort = SortExpr::new(vec![("created_at".to_string(), OrderDirection::Desc)]);
689
690        match expr {
691            FilterExpr::And(items) => assert_eq!(items.len(), 2),
692            other => panic!("expected And fixture, got {other:?}"),
693        }
694
695        assert_eq!(sort.fields().len(), 1);
696        assert!(matches!(sort.fields()[0].1, OrderDirection::Desc));
697    }
698}