Skip to main content

icydb_core/db/query/
expr.rs

1//! Module: query::expr
2//! Responsibility: schema-agnostic filter/order expression wrappers and lowering.
3//! Does not own: planner route selection or executor evaluation.
4//! Boundary: intent boundary lowers these to validated predicate/order forms.
5
6use crate::db::query::{
7    builder::FieldRef,
8    builder::{AggregateExpr, NumericProjectionExpr, RoundProjectionExpr, TextProjectionExpr},
9    plan::{
10        OrderDirection, OrderTerm as PlannedOrderTerm,
11        expr::{BinaryOp, Expr, FieldId, Function, UnaryOp},
12    },
13};
14use crate::{traits::FieldValue, value::Value};
15use candid::CandidType;
16use serde::Deserialize;
17
18///
19/// FilterExpr
20///
21/// Serialized, planner-agnostic filter language.
22/// This is the shared typed filter input model for fluent callers and lowers
23/// directly onto planner-owned boolean expressions at the intent boundary.
24///
25
26#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
27#[serde(rename_all = "PascalCase")]
28pub enum FilterExpr {
29    True,
30    False,
31    And(Vec<Self>),
32    Or(Vec<Self>),
33    Not(Box<Self>),
34    Eq {
35        field: String,
36        value: Value,
37    },
38    EqCi {
39        field: String,
40        value: Value,
41    },
42    Ne {
43        field: String,
44        value: Value,
45    },
46    Lt {
47        field: String,
48        value: Value,
49    },
50    Lte {
51        field: String,
52        value: Value,
53    },
54    Gt {
55        field: String,
56        value: Value,
57    },
58    Gte {
59        field: String,
60        value: Value,
61    },
62    EqField {
63        left_field: String,
64        right_field: String,
65    },
66    NeField {
67        left_field: String,
68        right_field: String,
69    },
70    LtField {
71        left_field: String,
72        right_field: String,
73    },
74    LteField {
75        left_field: String,
76        right_field: String,
77    },
78    GtField {
79        left_field: String,
80        right_field: String,
81    },
82    GteField {
83        left_field: String,
84        right_field: String,
85    },
86    In {
87        field: String,
88        values: Vec<Value>,
89    },
90    NotIn {
91        field: String,
92        values: Vec<Value>,
93    },
94    Contains {
95        field: String,
96        value: Value,
97    },
98    TextContains {
99        field: String,
100        value: Value,
101    },
102    TextContainsCi {
103        field: String,
104        value: Value,
105    },
106    StartsWith {
107        field: String,
108        value: Value,
109    },
110    StartsWithCi {
111        field: String,
112        value: Value,
113    },
114    EndsWith {
115        field: String,
116        value: Value,
117    },
118    EndsWithCi {
119        field: String,
120        value: Value,
121    },
122    IsNull {
123        field: String,
124    },
125    IsNotNull {
126        field: String,
127    },
128    IsMissing {
129        field: String,
130    },
131    IsEmpty {
132        field: String,
133    },
134    IsNotEmpty {
135        field: String,
136    },
137}
138
139impl FilterExpr {
140    /// Lower this typed filter expression into the shared planner-owned boolean expression model.
141    #[must_use]
142    pub(in crate::db) fn lower_bool_expr(&self) -> Expr {
143        match self {
144            Self::True => Expr::Literal(Value::Bool(true)),
145            Self::False => Expr::Literal(Value::Bool(false)),
146            Self::And(xs) => fold_filter_bool_chain(BinaryOp::And, xs),
147            Self::Or(xs) => fold_filter_bool_chain(BinaryOp::Or, xs),
148            Self::Not(x) => Expr::Unary {
149                op: UnaryOp::Not,
150                expr: Box::new(x.lower_bool_expr()),
151            },
152            Self::Eq { field, value } => field_compare_expr(BinaryOp::Eq, field, value.clone()),
153            Self::EqCi { field, value } => Expr::Binary {
154                op: BinaryOp::Eq,
155                left: Box::new(casefold_field_expr(field)),
156                right: Box::new(Expr::Literal(value.clone())),
157            },
158            Self::Ne { field, value } => field_compare_expr(BinaryOp::Ne, field, value.clone()),
159            Self::Lt { field, value } => field_compare_expr(BinaryOp::Lt, field, value.clone()),
160            Self::Lte { field, value } => field_compare_expr(BinaryOp::Lte, field, value.clone()),
161            Self::Gt { field, value } => field_compare_expr(BinaryOp::Gt, field, value.clone()),
162            Self::Gte { field, value } => field_compare_expr(BinaryOp::Gte, field, value.clone()),
163            Self::EqField {
164                left_field,
165                right_field,
166            } => field_compare_field_expr(BinaryOp::Eq, left_field, right_field),
167            Self::NeField {
168                left_field,
169                right_field,
170            } => field_compare_field_expr(BinaryOp::Ne, left_field, right_field),
171            Self::LtField {
172                left_field,
173                right_field,
174            } => field_compare_field_expr(BinaryOp::Lt, left_field, right_field),
175            Self::LteField {
176                left_field,
177                right_field,
178            } => field_compare_field_expr(BinaryOp::Lte, left_field, right_field),
179            Self::GtField {
180                left_field,
181                right_field,
182            } => field_compare_field_expr(BinaryOp::Gt, left_field, right_field),
183            Self::GteField {
184                left_field,
185                right_field,
186            } => field_compare_field_expr(BinaryOp::Gte, left_field, right_field),
187            Self::In { field, values } => membership_expr(field, values.as_slice(), false),
188            Self::NotIn { field, values } => membership_expr(field, values.as_slice(), true),
189            Self::Contains { field, value } => Expr::FunctionCall {
190                function: Function::CollectionContains,
191                args: vec![
192                    Expr::Field(FieldId::new(field.clone())),
193                    Expr::Literal(value.clone()),
194                ],
195            },
196            Self::TextContains { field, value } => text_function_expr(
197                Function::Contains,
198                Expr::Field(FieldId::new(field.clone())),
199                value.clone(),
200            ),
201            Self::TextContainsCi { field, value } => text_function_expr(
202                Function::Contains,
203                casefold_field_expr(field),
204                value.clone(),
205            ),
206            Self::StartsWith { field, value } => text_function_expr(
207                Function::StartsWith,
208                Expr::Field(FieldId::new(field.clone())),
209                value.clone(),
210            ),
211            Self::StartsWithCi { field, value } => text_function_expr(
212                Function::StartsWith,
213                casefold_field_expr(field),
214                value.clone(),
215            ),
216            Self::EndsWith { field, value } => text_function_expr(
217                Function::EndsWith,
218                Expr::Field(FieldId::new(field.clone())),
219                value.clone(),
220            ),
221            Self::EndsWithCi { field, value } => text_function_expr(
222                Function::EndsWith,
223                casefold_field_expr(field),
224                value.clone(),
225            ),
226            Self::IsNull { field } => field_function_expr(Function::IsNull, field),
227            Self::IsNotNull { field } => field_function_expr(Function::IsNotNull, field),
228            Self::IsMissing { field } => field_function_expr(Function::IsMissing, field),
229            Self::IsEmpty { field } => field_function_expr(Function::IsEmpty, field),
230            Self::IsNotEmpty { field } => field_function_expr(Function::IsNotEmpty, field),
231        }
232    }
233
234    /// Build an `And` expression from a list of child expressions.
235    #[must_use]
236    pub const fn and(exprs: Vec<Self>) -> Self {
237        Self::And(exprs)
238    }
239
240    /// Build an `Or` expression from a list of child expressions.
241    #[must_use]
242    pub const fn or(exprs: Vec<Self>) -> Self {
243        Self::Or(exprs)
244    }
245
246    /// Negate one child expression.
247    #[must_use]
248    #[expect(clippy::should_implement_trait)]
249    pub fn not(expr: Self) -> Self {
250        Self::Not(Box::new(expr))
251    }
252
253    /// Compare `field == value`.
254    #[must_use]
255    pub fn eq(field: impl Into<String>, value: impl FieldValue) -> Self {
256        Self::Eq {
257            field: field.into(),
258            value: value.to_value(),
259        }
260    }
261
262    /// Compare `field != value`.
263    #[must_use]
264    pub fn ne(field: impl Into<String>, value: impl FieldValue) -> Self {
265        Self::Ne {
266            field: field.into(),
267            value: value.to_value(),
268        }
269    }
270
271    /// Compare `field < value`.
272    #[must_use]
273    pub fn lt(field: impl Into<String>, value: impl FieldValue) -> Self {
274        Self::Lt {
275            field: field.into(),
276            value: value.to_value(),
277        }
278    }
279
280    /// Compare `field <= value`.
281    #[must_use]
282    pub fn lte(field: impl Into<String>, value: impl FieldValue) -> Self {
283        Self::Lte {
284            field: field.into(),
285            value: value.to_value(),
286        }
287    }
288
289    /// Compare `field > value`.
290    #[must_use]
291    pub fn gt(field: impl Into<String>, value: impl FieldValue) -> Self {
292        Self::Gt {
293            field: field.into(),
294            value: value.to_value(),
295        }
296    }
297
298    /// Compare `field >= value`.
299    #[must_use]
300    pub fn gte(field: impl Into<String>, value: impl FieldValue) -> Self {
301        Self::Gte {
302            field: field.into(),
303            value: value.to_value(),
304        }
305    }
306
307    /// Compare `field == value` with casefolded text equality.
308    #[must_use]
309    pub fn eq_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
310        Self::EqCi {
311            field: field.into(),
312            value: value.to_value(),
313        }
314    }
315
316    /// Compare `left_field == right_field`.
317    #[must_use]
318    pub fn eq_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
319        Self::EqField {
320            left_field: left_field.into(),
321            right_field: right_field.into(),
322        }
323    }
324
325    /// Compare `left_field != right_field`.
326    #[must_use]
327    pub fn ne_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
328        Self::NeField {
329            left_field: left_field.into(),
330            right_field: right_field.into(),
331        }
332    }
333
334    /// Compare `left_field < right_field`.
335    #[must_use]
336    pub fn lt_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
337        Self::LtField {
338            left_field: left_field.into(),
339            right_field: right_field.into(),
340        }
341    }
342
343    /// Compare `left_field <= right_field`.
344    #[must_use]
345    pub fn lte_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
346        Self::LteField {
347            left_field: left_field.into(),
348            right_field: right_field.into(),
349        }
350    }
351
352    /// Compare `left_field > right_field`.
353    #[must_use]
354    pub fn gt_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
355        Self::GtField {
356            left_field: left_field.into(),
357            right_field: right_field.into(),
358        }
359    }
360
361    /// Compare `left_field >= right_field`.
362    #[must_use]
363    pub fn gte_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
364        Self::GteField {
365            left_field: left_field.into(),
366            right_field: right_field.into(),
367        }
368    }
369
370    /// Compare `field IN values`.
371    #[must_use]
372    pub fn in_list(
373        field: impl Into<String>,
374        values: impl IntoIterator<Item = impl FieldValue>,
375    ) -> Self {
376        Self::In {
377            field: field.into(),
378            values: values.into_iter().map(|value| value.to_value()).collect(),
379        }
380    }
381
382    /// Compare `field NOT IN values`.
383    #[must_use]
384    pub fn not_in(
385        field: impl Into<String>,
386        values: impl IntoIterator<Item = impl FieldValue>,
387    ) -> Self {
388        Self::NotIn {
389            field: field.into(),
390            values: values.into_iter().map(|value| value.to_value()).collect(),
391        }
392    }
393
394    /// Compare collection `field CONTAINS value`.
395    #[must_use]
396    pub fn contains(field: impl Into<String>, value: impl FieldValue) -> Self {
397        Self::Contains {
398            field: field.into(),
399            value: value.to_value(),
400        }
401    }
402
403    /// Compare case-sensitive substring containment.
404    #[must_use]
405    pub fn text_contains(field: impl Into<String>, value: impl FieldValue) -> Self {
406        Self::TextContains {
407            field: field.into(),
408            value: value.to_value(),
409        }
410    }
411
412    /// Compare case-insensitive substring containment.
413    #[must_use]
414    pub fn text_contains_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
415        Self::TextContainsCi {
416            field: field.into(),
417            value: value.to_value(),
418        }
419    }
420
421    /// Compare case-sensitive prefix match.
422    #[must_use]
423    pub fn starts_with(field: impl Into<String>, value: impl FieldValue) -> Self {
424        Self::StartsWith {
425            field: field.into(),
426            value: value.to_value(),
427        }
428    }
429
430    /// Compare case-insensitive prefix match.
431    #[must_use]
432    pub fn starts_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
433        Self::StartsWithCi {
434            field: field.into(),
435            value: value.to_value(),
436        }
437    }
438
439    /// Compare case-sensitive suffix match.
440    #[must_use]
441    pub fn ends_with(field: impl Into<String>, value: impl FieldValue) -> Self {
442        Self::EndsWith {
443            field: field.into(),
444            value: value.to_value(),
445        }
446    }
447
448    /// Compare case-insensitive suffix match.
449    #[must_use]
450    pub fn ends_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
451        Self::EndsWithCi {
452            field: field.into(),
453            value: value.to_value(),
454        }
455    }
456
457    /// Match rows where `field` is present and null.
458    #[must_use]
459    pub fn is_null(field: impl Into<String>) -> Self {
460        Self::IsNull {
461            field: field.into(),
462        }
463    }
464
465    /// Match rows where `field` is present and non-null.
466    #[must_use]
467    pub fn is_not_null(field: impl Into<String>) -> Self {
468        Self::IsNotNull {
469            field: field.into(),
470        }
471    }
472
473    /// Match rows where `field` is absent.
474    #[must_use]
475    pub fn is_missing(field: impl Into<String>) -> Self {
476        Self::IsMissing {
477            field: field.into(),
478        }
479    }
480
481    /// Match rows where `field` is present and empty.
482    #[must_use]
483    pub fn is_empty(field: impl Into<String>) -> Self {
484        Self::IsEmpty {
485            field: field.into(),
486        }
487    }
488
489    /// Match rows where `field` is present and non-empty.
490    #[must_use]
491    pub fn is_not_empty(field: impl Into<String>) -> Self {
492        Self::IsNotEmpty {
493            field: field.into(),
494        }
495    }
496}
497
498fn fold_filter_bool_chain(op: BinaryOp, exprs: &[FilterExpr]) -> Expr {
499    let mut exprs = exprs.iter();
500    let Some(first) = exprs.next() else {
501        return Expr::Literal(Value::Bool(matches!(op, BinaryOp::And)));
502    };
503
504    let first = first.lower_bool_expr();
505
506    exprs.fold(first, |left, expr| Expr::Binary {
507        op,
508        left: Box::new(left),
509        right: Box::new(expr.lower_bool_expr()),
510    })
511}
512
513fn field_compare_expr(op: BinaryOp, field: &str, value: Value) -> Expr {
514    Expr::Binary {
515        op,
516        left: Box::new(Expr::Field(FieldId::new(field.to_string()))),
517        right: Box::new(Expr::Literal(value)),
518    }
519}
520
521fn field_compare_field_expr(op: BinaryOp, left_field: &str, right_field: &str) -> Expr {
522    Expr::Binary {
523        op,
524        left: Box::new(Expr::Field(FieldId::new(left_field.to_string()))),
525        right: Box::new(Expr::Field(FieldId::new(right_field.to_string()))),
526    }
527}
528
529fn membership_expr(field: &str, values: &[Value], negated: bool) -> Expr {
530    let compare_op = if negated { BinaryOp::Ne } else { BinaryOp::Eq };
531    let join_op = if negated { BinaryOp::And } else { BinaryOp::Or };
532    let mut values = values.iter();
533    let Some(first) = values.next() else {
534        return Expr::Literal(Value::Bool(negated));
535    };
536
537    let field = Expr::Field(FieldId::new(field.to_string()));
538    let mut expr = Expr::Binary {
539        op: compare_op,
540        left: Box::new(field.clone()),
541        right: Box::new(Expr::Literal(first.clone())),
542    };
543
544    for value in values {
545        expr = Expr::Binary {
546            op: join_op,
547            left: Box::new(expr),
548            right: Box::new(Expr::Binary {
549                op: compare_op,
550                left: Box::new(field.clone()),
551                right: Box::new(Expr::Literal(value.clone())),
552            }),
553        };
554    }
555
556    expr
557}
558
559fn field_function_expr(function: Function, field: &str) -> Expr {
560    Expr::FunctionCall {
561        function,
562        args: vec![Expr::Field(FieldId::new(field.to_string()))],
563    }
564}
565
566fn text_function_expr(function: Function, left: Expr, value: Value) -> Expr {
567    Expr::FunctionCall {
568        function,
569        args: vec![left, Expr::Literal(value)],
570    }
571}
572
573fn casefold_field_expr(field: &str) -> Expr {
574    Expr::FunctionCall {
575        function: Function::Lower,
576        args: vec![Expr::Field(FieldId::new(field.to_string()))],
577    }
578}
579
580///
581/// OrderExpr
582///
583/// Typed fluent ORDER BY expression wrapper.
584/// This exists so fluent code can construct planner-owned ORDER BY
585/// semantics directly at the query boundary.
586///
587
588#[derive(Clone, Debug, Eq, PartialEq)]
589pub struct OrderExpr {
590    expr: Expr,
591}
592
593impl OrderExpr {
594    /// Build one direct field ORDER BY expression.
595    #[must_use]
596    pub fn field(field: impl Into<String>) -> Self {
597        let field = field.into();
598
599        Self {
600            expr: Expr::Field(FieldId::new(field)),
601        }
602    }
603
604    // Freeze one typed fluent order expression onto the planner-owned
605    // semantic expression now that labels are derived only at explain/hash
606    // edges instead of being stored in fluent order shells.
607    const fn new(expr: Expr) -> Self {
608        Self { expr }
609    }
610
611    // Lower one typed fluent order expression into the planner-owned order
612    // contract now that ordering is expression-based end to end.
613    pub(in crate::db) fn lower(&self, direction: OrderDirection) -> PlannedOrderTerm {
614        PlannedOrderTerm::new(self.expr.clone(), direction)
615    }
616}
617
618impl From<&str> for OrderExpr {
619    fn from(value: &str) -> Self {
620        Self::field(value)
621    }
622}
623
624impl From<String> for OrderExpr {
625    fn from(value: String) -> Self {
626        Self::field(value)
627    }
628}
629
630impl From<FieldRef> for OrderExpr {
631    fn from(value: FieldRef) -> Self {
632        Self::field(value.as_str())
633    }
634}
635
636impl From<TextProjectionExpr> for OrderExpr {
637    fn from(value: TextProjectionExpr) -> Self {
638        Self::new(value.expr().clone())
639    }
640}
641
642impl From<NumericProjectionExpr> for OrderExpr {
643    fn from(value: NumericProjectionExpr) -> Self {
644        Self::new(value.expr().clone())
645    }
646}
647
648impl From<RoundProjectionExpr> for OrderExpr {
649    fn from(value: RoundProjectionExpr) -> Self {
650        Self::new(value.expr().clone())
651    }
652}
653
654impl From<AggregateExpr> for OrderExpr {
655    fn from(value: AggregateExpr) -> Self {
656        Self::new(Expr::Aggregate(value))
657    }
658}
659
660///
661/// OrderTerm
662///
663/// Typed fluent ORDER BY term.
664/// Carries one typed ORDER BY expression plus direction so fluent builders can
665/// express deterministic ordering directly at the query boundary.
666///
667
668#[derive(Clone, Debug, Eq, PartialEq)]
669pub struct OrderTerm {
670    expr: OrderExpr,
671    direction: OrderDirection,
672}
673
674impl OrderTerm {
675    /// Build one ascending ORDER BY term from one typed expression.
676    #[must_use]
677    pub fn asc(expr: impl Into<OrderExpr>) -> Self {
678        Self {
679            expr: expr.into(),
680            direction: OrderDirection::Asc,
681        }
682    }
683
684    /// Build one descending ORDER BY term from one typed expression.
685    #[must_use]
686    pub fn desc(expr: impl Into<OrderExpr>) -> Self {
687        Self {
688            expr: expr.into(),
689            direction: OrderDirection::Desc,
690        }
691    }
692
693    // Lower one typed fluent order term directly into the planner-owned
694    // `OrderTerm` contract.
695    pub(in crate::db) fn lower(&self) -> PlannedOrderTerm {
696        self.expr.lower(self.direction)
697    }
698}
699
700/// Build one typed direct-field ORDER BY expression.
701#[must_use]
702pub fn field(field: impl Into<String>) -> OrderExpr {
703    OrderExpr::field(field)
704}
705
706/// Build one ascending typed ORDER BY term.
707#[must_use]
708pub fn asc(expr: impl Into<OrderExpr>) -> OrderTerm {
709    OrderTerm::asc(expr)
710}
711
712/// Build one descending typed ORDER BY term.
713#[must_use]
714pub fn desc(expr: impl Into<OrderExpr>) -> OrderTerm {
715    OrderTerm::desc(expr)
716}
717
718///
719/// TESTS
720///
721
722#[cfg(test)]
723mod tests {
724    use super::FilterExpr;
725    use candid::types::{CandidType, Label, Type, TypeInner};
726
727    fn expect_record_fields(ty: Type) -> Vec<String> {
728        match ty.as_ref() {
729            TypeInner::Record(fields) => fields
730                .iter()
731                .map(|field| match field.id.as_ref() {
732                    Label::Named(name) => name.clone(),
733                    other => panic!("expected named record field, got {other:?}"),
734                })
735                .collect(),
736            other => panic!("expected candid record, got {other:?}"),
737        }
738    }
739
740    fn expect_variant_labels(ty: Type) -> Vec<String> {
741        match ty.as_ref() {
742            TypeInner::Variant(fields) => fields
743                .iter()
744                .map(|field| match field.id.as_ref() {
745                    Label::Named(name) => name.clone(),
746                    other => panic!("expected named variant label, got {other:?}"),
747                })
748                .collect(),
749            other => panic!("expected candid variant, got {other:?}"),
750        }
751    }
752
753    fn expect_variant_field_type(ty: Type, variant_name: &str) -> Type {
754        match ty.as_ref() {
755            TypeInner::Variant(fields) => fields
756                .iter()
757                .find_map(|field| match field.id.as_ref() {
758                    Label::Named(name) if name == variant_name => Some(field.ty.clone()),
759                    _ => None,
760                })
761                .unwrap_or_else(|| panic!("expected variant label `{variant_name}`")),
762            other => panic!("expected candid variant, got {other:?}"),
763        }
764    }
765
766    #[test]
767    fn filter_expr_eq_candid_payload_shape_is_stable() {
768        let fields = expect_record_fields(expect_variant_field_type(FilterExpr::ty(), "Eq"));
769
770        for field in ["field", "value"] {
771            assert!(
772                fields.iter().any(|candidate| candidate == field),
773                "Eq payload must keep `{field}` field key in Candid shape",
774            );
775        }
776    }
777
778    #[test]
779    fn filter_expr_and_candid_payload_shape_is_stable() {
780        match expect_variant_field_type(FilterExpr::ty(), "And").as_ref() {
781            TypeInner::Vec(_) => {}
782            other => panic!("And payload must remain a Candid vec payload, got {other:?}"),
783        }
784    }
785
786    #[test]
787    fn filter_expr_text_contains_ci_candid_payload_shape_is_stable() {
788        let fields = expect_record_fields(expect_variant_field_type(
789            FilterExpr::ty(),
790            "TextContainsCi",
791        ));
792
793        for field in ["field", "value"] {
794            assert!(
795                fields.iter().any(|candidate| candidate == field),
796                "TextContainsCi payload must keep `{field}` field key in Candid shape",
797            );
798        }
799    }
800
801    #[test]
802    fn filter_expr_not_payload_shape_is_stable() {
803        match expect_variant_field_type(FilterExpr::ty(), "Not").as_ref() {
804            TypeInner::Var(_) | TypeInner::Knot(_) | TypeInner::Variant(_) => {}
805            other => panic!("Not payload must keep nested predicate payload, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn filter_expr_variant_labels_are_stable() {
811        let labels = expect_variant_labels(FilterExpr::ty());
812
813        for label in ["Eq", "And", "Not", "TextContainsCi", "IsMissing"] {
814            assert!(
815                labels.iter().any(|candidate| candidate == label),
816                "FilterExpr must keep `{label}` variant label",
817            );
818        }
819    }
820
821    #[test]
822    fn query_expr_fixture_constructors_stay_usable() {
823        let expr = FilterExpr::and(vec![
824            FilterExpr::is_null("deleted_at"),
825            FilterExpr::not(FilterExpr::is_missing("name")),
826        ]);
827
828        match expr {
829            FilterExpr::And(items) => assert_eq!(items.len(), 2),
830            other => panic!("expected And fixture, got {other:?}"),
831        }
832    }
833}