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::canonicalize_filter_literal_for_kind,
10    plan::{
11        OrderDirection, OrderTerm as PlannedOrderTerm,
12        expr::{BinaryOp, Expr, FieldId, Function, UnaryOp},
13    },
14};
15use crate::{
16    model::EntityModel,
17    value::{InputValue, Value},
18};
19use candid::CandidType;
20use serde::Deserialize;
21
22///
23/// FilterValue
24///
25/// Serialized frontend-safe filter literal payload.
26/// This keeps the public filter wire surface narrow and string-backed while
27/// the intent boundary still rehydrates typed runtime values from schema.
28///
29
30#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
31pub enum FilterValue {
32    String(String),
33    Bool(bool),
34    Null,
35    List(Vec<Self>),
36}
37
38impl FilterValue {
39    // Convert one typed runtime value onto the narrowed public filter wire
40    // contract. Non-bool scalar values travel as canonical strings so the
41    // schema-aware intent boundary can rehydrate the exact field kind later.
42    fn from_typed_value(value: Value) -> Self {
43        match value {
44            Value::Bool(value) => Self::Bool(value),
45            Value::List(values) => {
46                Self::List(values.into_iter().map(Self::from_typed_value).collect())
47            }
48            Value::Null | Value::Unit => Self::Null,
49            Value::Text(value) => Self::String(value),
50            Value::Enum(value) => Self::String(value.variant().to_string()),
51            Value::Account(value) => Self::String(value.to_string()),
52            Value::Blob(value) => Self::String(format!("{value:?}")),
53            Value::Date(value) => Self::String(value.to_string()),
54            Value::Decimal(value) => Self::String(value.to_string()),
55            Value::Duration(value) => Self::String(format!("{value:?}")),
56            Value::Float32(value) => Self::String(value.to_string()),
57            Value::Float64(value) => Self::String(value.to_string()),
58            Value::Int(value) => Self::String(value.to_string()),
59            Value::Int128(value) => Self::String(value.to_string()),
60            Value::IntBig(value) => Self::String(value.to_string()),
61            Value::Map(value) => Self::String(format!("{value:?}")),
62            Value::Principal(value) => Self::String(value.to_string()),
63            Value::Subaccount(value) => Self::String(value.to_string()),
64            Value::Timestamp(value) => Self::String(value.to_string()),
65            Value::Uint(value) => Self::String(value.to_string()),
66            Value::Uint128(value) => Self::String(value.to_string()),
67            Value::UintBig(value) => Self::String(value.to_string()),
68            Value::Ulid(value) => Self::String(value.to_string()),
69        }
70    }
71
72    // Lower one public wire literal back onto the runtime value model before
73    // adjacent schema-aware callers optionally canonicalize it to the target field kind.
74    fn lower_value(&self) -> Value {
75        match self {
76            Self::String(value) => Value::Text(value.clone()),
77            Self::Bool(value) => Value::Bool(*value),
78            Self::Null => Value::Null,
79            Self::List(values) => Value::List(values.iter().map(Self::lower_value).collect()),
80        }
81    }
82
83    fn from_input_value(value: InputValue) -> Self {
84        Self::from_typed_value(Value::from(value))
85    }
86}
87
88impl<T> From<T> for FilterValue
89where
90    T: Into<InputValue>,
91{
92    fn from(value: T) -> Self {
93        Self::from_input_value(value.into())
94    }
95}
96
97///
98/// FilterExpr
99///
100/// Serialized, planner-agnostic filter language.
101/// This is the shared frontend-facing filter input model for fluent callers
102/// and lowers onto planner-owned boolean expressions at the intent boundary.
103///
104
105#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
106pub enum FilterExpr {
107    True,
108    False,
109    And(Vec<Self>),
110    Or(Vec<Self>),
111    Not(Box<Self>),
112    Eq {
113        field: String,
114        value: FilterValue,
115    },
116    EqCi {
117        field: String,
118        value: FilterValue,
119    },
120    Ne {
121        field: String,
122        value: FilterValue,
123    },
124    Lt {
125        field: String,
126        value: FilterValue,
127    },
128    Lte {
129        field: String,
130        value: FilterValue,
131    },
132    Gt {
133        field: String,
134        value: FilterValue,
135    },
136    Gte {
137        field: String,
138        value: FilterValue,
139    },
140    EqField {
141        left_field: String,
142        right_field: String,
143    },
144    NeField {
145        left_field: String,
146        right_field: String,
147    },
148    LtField {
149        left_field: String,
150        right_field: String,
151    },
152    LteField {
153        left_field: String,
154        right_field: String,
155    },
156    GtField {
157        left_field: String,
158        right_field: String,
159    },
160    GteField {
161        left_field: String,
162        right_field: String,
163    },
164    In {
165        field: String,
166        values: Vec<FilterValue>,
167    },
168    NotIn {
169        field: String,
170        values: Vec<FilterValue>,
171    },
172    Contains {
173        field: String,
174        value: FilterValue,
175    },
176    TextContains {
177        field: String,
178        value: FilterValue,
179    },
180    TextContainsCi {
181        field: String,
182        value: FilterValue,
183    },
184    StartsWith {
185        field: String,
186        value: FilterValue,
187    },
188    StartsWithCi {
189        field: String,
190        value: FilterValue,
191    },
192    EndsWith {
193        field: String,
194        value: FilterValue,
195    },
196    EndsWithCi {
197        field: String,
198        value: FilterValue,
199    },
200    IsNull {
201        field: String,
202    },
203    IsNotNull {
204        field: String,
205    },
206    IsMissing {
207        field: String,
208    },
209    IsEmpty {
210        field: String,
211    },
212    IsNotEmpty {
213        field: String,
214    },
215}
216
217impl FilterExpr {
218    /// Lower this typed filter expression into the shared planner-owned boolean expression model.
219    #[must_use]
220    #[expect(clippy::too_many_lines)]
221    pub(in crate::db) fn lower_bool_expr_for_model(&self, model: &EntityModel) -> Expr {
222        match self {
223            Self::True => Expr::Literal(Value::Bool(true)),
224            Self::False => Expr::Literal(Value::Bool(false)),
225            Self::And(xs) => fold_filter_bool_chain(BinaryOp::And, xs, model),
226            Self::Or(xs) => fold_filter_bool_chain(BinaryOp::Or, xs, model),
227            Self::Not(x) => Expr::Unary {
228                op: UnaryOp::Not,
229                expr: Box::new(x.lower_bool_expr_for_model(model)),
230            },
231            Self::Eq { field, value } => field_compare_expr(
232                BinaryOp::Eq,
233                field,
234                lower_compare_filter_value_for_field(model, field, value),
235            ),
236            Self::EqCi { field, value } => Expr::Binary {
237                op: BinaryOp::Eq,
238                left: Box::new(casefold_field_expr(field)),
239                right: Box::new(Expr::Literal(value.lower_value())),
240            },
241            Self::Ne { field, value } => field_compare_expr(
242                BinaryOp::Ne,
243                field,
244                lower_compare_filter_value_for_field(model, field, value),
245            ),
246            Self::Lt { field, value } => field_compare_expr(
247                BinaryOp::Lt,
248                field,
249                lower_compare_filter_value_for_field(model, field, value),
250            ),
251            Self::Lte { field, value } => field_compare_expr(
252                BinaryOp::Lte,
253                field,
254                lower_compare_filter_value_for_field(model, field, value),
255            ),
256            Self::Gt { field, value } => field_compare_expr(
257                BinaryOp::Gt,
258                field,
259                lower_compare_filter_value_for_field(model, field, value),
260            ),
261            Self::Gte { field, value } => field_compare_expr(
262                BinaryOp::Gte,
263                field,
264                lower_compare_filter_value_for_field(model, field, value),
265            ),
266            Self::EqField {
267                left_field,
268                right_field,
269            } => field_compare_field_expr(BinaryOp::Eq, left_field, right_field),
270            Self::NeField {
271                left_field,
272                right_field,
273            } => field_compare_field_expr(BinaryOp::Ne, left_field, right_field),
274            Self::LtField {
275                left_field,
276                right_field,
277            } => field_compare_field_expr(BinaryOp::Lt, left_field, right_field),
278            Self::LteField {
279                left_field,
280                right_field,
281            } => field_compare_field_expr(BinaryOp::Lte, left_field, right_field),
282            Self::GtField {
283                left_field,
284                right_field,
285            } => field_compare_field_expr(BinaryOp::Gt, left_field, right_field),
286            Self::GteField {
287                left_field,
288                right_field,
289            } => field_compare_field_expr(BinaryOp::Gte, left_field, right_field),
290            Self::In { field, values } => membership_expr(
291                field,
292                lower_membership_filter_values_for_field(model, field, values).as_slice(),
293                false,
294            ),
295            Self::NotIn { field, values } => membership_expr(
296                field,
297                lower_membership_filter_values_for_field(model, field, values).as_slice(),
298                true,
299            ),
300            Self::Contains { field, value } => Expr::FunctionCall {
301                function: Function::CollectionContains,
302                args: vec![
303                    Expr::Field(FieldId::new(field.clone())),
304                    Expr::Literal(lower_contains_filter_value_for_field(model, field, value)),
305                ],
306            },
307            Self::TextContains { field, value } => text_function_expr(
308                Function::Contains,
309                Expr::Field(FieldId::new(field.clone())),
310                value.lower_value(),
311            ),
312            Self::TextContainsCi { field, value } => text_function_expr(
313                Function::Contains,
314                casefold_field_expr(field),
315                value.lower_value(),
316            ),
317            Self::StartsWith { field, value } => text_function_expr(
318                Function::StartsWith,
319                Expr::Field(FieldId::new(field.clone())),
320                value.lower_value(),
321            ),
322            Self::StartsWithCi { field, value } => text_function_expr(
323                Function::StartsWith,
324                casefold_field_expr(field),
325                value.lower_value(),
326            ),
327            Self::EndsWith { field, value } => text_function_expr(
328                Function::EndsWith,
329                Expr::Field(FieldId::new(field.clone())),
330                value.lower_value(),
331            ),
332            Self::EndsWithCi { field, value } => text_function_expr(
333                Function::EndsWith,
334                casefold_field_expr(field),
335                value.lower_value(),
336            ),
337            Self::IsNull { field } => field_function_expr(Function::IsNull, field),
338            Self::IsNotNull { field } => field_function_expr(Function::IsNotNull, field),
339            Self::IsMissing { field } => field_function_expr(Function::IsMissing, field),
340            Self::IsEmpty { field } => field_function_expr(Function::IsEmpty, field),
341            Self::IsNotEmpty { field } => field_function_expr(Function::IsNotEmpty, field),
342        }
343    }
344
345    /// Build an `And` expression from a list of child expressions.
346    #[must_use]
347    pub const fn and(exprs: Vec<Self>) -> Self {
348        Self::And(exprs)
349    }
350
351    /// Build an `Or` expression from a list of child expressions.
352    #[must_use]
353    pub const fn or(exprs: Vec<Self>) -> Self {
354        Self::Or(exprs)
355    }
356
357    /// Negate one child expression.
358    #[must_use]
359    #[expect(clippy::should_implement_trait)]
360    pub fn not(expr: Self) -> Self {
361        Self::Not(Box::new(expr))
362    }
363
364    /// Compare `field == value`.
365    #[must_use]
366    pub fn eq(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
367        Self::Eq {
368            field: field.into(),
369            value: value.into(),
370        }
371    }
372
373    /// Compare `field != value`.
374    #[must_use]
375    pub fn ne(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
376        Self::Ne {
377            field: field.into(),
378            value: value.into(),
379        }
380    }
381
382    /// Compare `field < value`.
383    #[must_use]
384    pub fn lt(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
385        Self::Lt {
386            field: field.into(),
387            value: value.into(),
388        }
389    }
390
391    /// Compare `field <= value`.
392    #[must_use]
393    pub fn lte(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
394        Self::Lte {
395            field: field.into(),
396            value: value.into(),
397        }
398    }
399
400    /// Compare `field > value`.
401    #[must_use]
402    pub fn gt(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
403        Self::Gt {
404            field: field.into(),
405            value: value.into(),
406        }
407    }
408
409    /// Compare `field >= value`.
410    #[must_use]
411    pub fn gte(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
412        Self::Gte {
413            field: field.into(),
414            value: value.into(),
415        }
416    }
417
418    /// Compare `field == value` with casefolded text equality.
419    #[must_use]
420    pub fn eq_ci(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
421        Self::EqCi {
422            field: field.into(),
423            value: value.into(),
424        }
425    }
426
427    /// Compare `left_field == right_field`.
428    #[must_use]
429    pub fn eq_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
430        Self::EqField {
431            left_field: left_field.into(),
432            right_field: right_field.into(),
433        }
434    }
435
436    /// Compare `left_field != right_field`.
437    #[must_use]
438    pub fn ne_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
439        Self::NeField {
440            left_field: left_field.into(),
441            right_field: right_field.into(),
442        }
443    }
444
445    /// Compare `left_field < right_field`.
446    #[must_use]
447    pub fn lt_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
448        Self::LtField {
449            left_field: left_field.into(),
450            right_field: right_field.into(),
451        }
452    }
453
454    /// Compare `left_field <= right_field`.
455    #[must_use]
456    pub fn lte_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
457        Self::LteField {
458            left_field: left_field.into(),
459            right_field: right_field.into(),
460        }
461    }
462
463    /// Compare `left_field > right_field`.
464    #[must_use]
465    pub fn gt_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
466        Self::GtField {
467            left_field: left_field.into(),
468            right_field: right_field.into(),
469        }
470    }
471
472    /// Compare `left_field >= right_field`.
473    #[must_use]
474    pub fn gte_field(left_field: impl Into<String>, right_field: impl Into<String>) -> Self {
475        Self::GteField {
476            left_field: left_field.into(),
477            right_field: right_field.into(),
478        }
479    }
480
481    /// Compare `field IN values`.
482    #[must_use]
483    pub fn in_list(
484        field: impl Into<String>,
485        values: impl IntoIterator<Item = impl Into<FilterValue>>,
486    ) -> Self {
487        Self::In {
488            field: field.into(),
489            values: values.into_iter().map(Into::into).collect(),
490        }
491    }
492
493    /// Compare `field NOT IN values`.
494    #[must_use]
495    pub fn not_in(
496        field: impl Into<String>,
497        values: impl IntoIterator<Item = impl Into<FilterValue>>,
498    ) -> Self {
499        Self::NotIn {
500            field: field.into(),
501            values: values.into_iter().map(Into::into).collect(),
502        }
503    }
504
505    /// Compare collection `field CONTAINS value`.
506    #[must_use]
507    pub fn contains(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
508        Self::Contains {
509            field: field.into(),
510            value: value.into(),
511        }
512    }
513
514    /// Compare case-sensitive substring containment.
515    #[must_use]
516    pub fn text_contains(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
517        Self::TextContains {
518            field: field.into(),
519            value: value.into(),
520        }
521    }
522
523    /// Compare case-insensitive substring containment.
524    #[must_use]
525    pub fn text_contains_ci(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
526        Self::TextContainsCi {
527            field: field.into(),
528            value: value.into(),
529        }
530    }
531
532    /// Compare case-sensitive prefix match.
533    #[must_use]
534    pub fn starts_with(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
535        Self::StartsWith {
536            field: field.into(),
537            value: value.into(),
538        }
539    }
540
541    /// Compare case-insensitive prefix match.
542    #[must_use]
543    pub fn starts_with_ci(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
544        Self::StartsWithCi {
545            field: field.into(),
546            value: value.into(),
547        }
548    }
549
550    /// Compare case-sensitive suffix match.
551    #[must_use]
552    pub fn ends_with(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
553        Self::EndsWith {
554            field: field.into(),
555            value: value.into(),
556        }
557    }
558
559    /// Compare case-insensitive suffix match.
560    #[must_use]
561    pub fn ends_with_ci(field: impl Into<String>, value: impl Into<FilterValue>) -> Self {
562        Self::EndsWithCi {
563            field: field.into(),
564            value: value.into(),
565        }
566    }
567
568    /// Match rows where `field` is present and null.
569    #[must_use]
570    pub fn is_null(field: impl Into<String>) -> Self {
571        Self::IsNull {
572            field: field.into(),
573        }
574    }
575
576    /// Match rows where `field` is present and non-null.
577    #[must_use]
578    pub fn is_not_null(field: impl Into<String>) -> Self {
579        Self::IsNotNull {
580            field: field.into(),
581        }
582    }
583
584    /// Match rows where `field` is absent.
585    #[must_use]
586    pub fn is_missing(field: impl Into<String>) -> Self {
587        Self::IsMissing {
588            field: field.into(),
589        }
590    }
591
592    /// Match rows where `field` is present and empty.
593    #[must_use]
594    pub fn is_empty(field: impl Into<String>) -> Self {
595        Self::IsEmpty {
596            field: field.into(),
597        }
598    }
599
600    /// Match rows where `field` is present and non-empty.
601    #[must_use]
602    pub fn is_not_empty(field: impl Into<String>) -> Self {
603        Self::IsNotEmpty {
604            field: field.into(),
605        }
606    }
607}
608
609fn fold_filter_bool_chain(op: BinaryOp, exprs: &[FilterExpr], model: &EntityModel) -> Expr {
610    let mut exprs = exprs.iter();
611    let Some(first) = exprs.next() else {
612        return Expr::Literal(Value::Bool(matches!(op, BinaryOp::And)));
613    };
614
615    let first = first.lower_bool_expr_for_model(model);
616
617    exprs.fold(first, |left, expr| Expr::Binary {
618        op,
619        left: Box::new(left),
620        right: Box::new(expr.lower_bool_expr_for_model(model)),
621    })
622}
623
624fn lower_compare_filter_value_for_field(
625    model: &EntityModel,
626    field: &str,
627    value: &FilterValue,
628) -> Value {
629    lower_filter_value_for_field_kind(model, field, value, false)
630}
631
632fn lower_contains_filter_value_for_field(
633    model: &EntityModel,
634    field: &str,
635    value: &FilterValue,
636) -> Value {
637    lower_filter_value_for_field_kind(model, field, value, true)
638}
639
640fn lower_filter_value_for_field_kind(
641    model: &EntityModel,
642    field: &str,
643    value: &FilterValue,
644    collection_element: bool,
645) -> Value {
646    let raw = value.lower_value();
647    let Some(field_slot) = model.resolve_field_slot(field) else {
648        return raw;
649    };
650
651    let mut kind = model.fields()[field_slot].kind();
652    if collection_element {
653        kind = match kind {
654            crate::model::field::FieldKind::List(inner)
655            | crate::model::field::FieldKind::Set(inner) => *inner,
656            _ => kind,
657        };
658    }
659
660    canonicalize_filter_literal_for_kind(&kind, &raw).unwrap_or(raw)
661}
662
663fn lower_membership_filter_values_for_field(
664    model: &EntityModel,
665    field: &str,
666    values: &[FilterValue],
667) -> Vec<Value> {
668    values
669        .iter()
670        .map(|value| lower_compare_filter_value_for_field(model, field, value))
671        .collect()
672}
673
674fn field_compare_expr(op: BinaryOp, field: &str, value: Value) -> Expr {
675    Expr::Binary {
676        op,
677        left: Box::new(Expr::Field(FieldId::new(field.to_string()))),
678        right: Box::new(Expr::Literal(value)),
679    }
680}
681
682fn field_compare_field_expr(op: BinaryOp, left_field: &str, right_field: &str) -> Expr {
683    Expr::Binary {
684        op,
685        left: Box::new(Expr::Field(FieldId::new(left_field.to_string()))),
686        right: Box::new(Expr::Field(FieldId::new(right_field.to_string()))),
687    }
688}
689
690fn membership_expr(field: &str, values: &[Value], negated: bool) -> Expr {
691    let compare_op = if negated { BinaryOp::Ne } else { BinaryOp::Eq };
692    let join_op = if negated { BinaryOp::And } else { BinaryOp::Or };
693    let mut values = values.iter();
694    let Some(first) = values.next() else {
695        return Expr::Literal(Value::Bool(negated));
696    };
697
698    let field = Expr::Field(FieldId::new(field.to_string()));
699    let mut expr = Expr::Binary {
700        op: compare_op,
701        left: Box::new(field.clone()),
702        right: Box::new(Expr::Literal(first.clone())),
703    };
704
705    for value in values {
706        expr = Expr::Binary {
707            op: join_op,
708            left: Box::new(expr),
709            right: Box::new(Expr::Binary {
710                op: compare_op,
711                left: Box::new(field.clone()),
712                right: Box::new(Expr::Literal(value.clone())),
713            }),
714        };
715    }
716
717    expr
718}
719
720fn field_function_expr(function: Function, field: &str) -> Expr {
721    Expr::FunctionCall {
722        function,
723        args: vec![Expr::Field(FieldId::new(field.to_string()))],
724    }
725}
726
727fn text_function_expr(function: Function, left: Expr, value: Value) -> Expr {
728    Expr::FunctionCall {
729        function,
730        args: vec![left, Expr::Literal(value)],
731    }
732}
733
734fn casefold_field_expr(field: &str) -> Expr {
735    Expr::FunctionCall {
736        function: Function::Lower,
737        args: vec![Expr::Field(FieldId::new(field.to_string()))],
738    }
739}
740
741///
742/// OrderExpr
743///
744/// Typed fluent ORDER BY expression wrapper.
745/// This exists so fluent code can construct planner-owned ORDER BY
746/// semantics directly at the query boundary.
747///
748
749#[derive(Clone, Debug, Eq, PartialEq)]
750pub struct OrderExpr {
751    expr: Expr,
752}
753
754impl OrderExpr {
755    /// Build one direct field ORDER BY expression.
756    #[must_use]
757    pub fn field(field: impl Into<String>) -> Self {
758        let field = field.into();
759
760        Self {
761            expr: Expr::Field(FieldId::new(field)),
762        }
763    }
764
765    // Freeze one typed fluent order expression onto the planner-owned
766    // semantic expression now that labels are derived only at explain/hash
767    // edges instead of being stored in fluent order shells.
768    const fn new(expr: Expr) -> Self {
769        Self { expr }
770    }
771
772    // Lower one typed fluent order expression into the planner-owned order
773    // contract now that ordering is expression-based end to end.
774    pub(in crate::db) fn lower(&self, direction: OrderDirection) -> PlannedOrderTerm {
775        PlannedOrderTerm::new(self.expr.clone(), direction)
776    }
777}
778
779impl From<&str> for OrderExpr {
780    fn from(value: &str) -> Self {
781        Self::field(value)
782    }
783}
784
785impl From<String> for OrderExpr {
786    fn from(value: String) -> Self {
787        Self::field(value)
788    }
789}
790
791impl From<FieldRef> for OrderExpr {
792    fn from(value: FieldRef) -> Self {
793        Self::field(value.as_str())
794    }
795}
796
797impl From<TextProjectionExpr> for OrderExpr {
798    fn from(value: TextProjectionExpr) -> Self {
799        Self::new(value.expr().clone())
800    }
801}
802
803impl From<NumericProjectionExpr> for OrderExpr {
804    fn from(value: NumericProjectionExpr) -> Self {
805        Self::new(value.expr().clone())
806    }
807}
808
809impl From<RoundProjectionExpr> for OrderExpr {
810    fn from(value: RoundProjectionExpr) -> Self {
811        Self::new(value.expr().clone())
812    }
813}
814
815impl From<AggregateExpr> for OrderExpr {
816    fn from(value: AggregateExpr) -> Self {
817        Self::new(Expr::Aggregate(value))
818    }
819}
820
821///
822/// OrderTerm
823///
824/// Typed fluent ORDER BY term.
825/// Carries one typed ORDER BY expression plus direction so fluent builders can
826/// express deterministic ordering directly at the query boundary.
827///
828
829#[derive(Clone, Debug, Eq, PartialEq)]
830pub struct OrderTerm {
831    expr: OrderExpr,
832    direction: OrderDirection,
833}
834
835impl OrderTerm {
836    /// Build one ascending ORDER BY term from one typed expression.
837    #[must_use]
838    pub fn asc(expr: impl Into<OrderExpr>) -> Self {
839        Self {
840            expr: expr.into(),
841            direction: OrderDirection::Asc,
842        }
843    }
844
845    /// Build one descending ORDER BY term from one typed expression.
846    #[must_use]
847    pub fn desc(expr: impl Into<OrderExpr>) -> Self {
848        Self {
849            expr: expr.into(),
850            direction: OrderDirection::Desc,
851        }
852    }
853
854    // Lower one typed fluent order term directly into the planner-owned
855    // `OrderTerm` contract.
856    pub(in crate::db) fn lower(&self) -> PlannedOrderTerm {
857        self.expr.lower(self.direction)
858    }
859}
860
861/// Build one typed direct-field ORDER BY expression.
862#[must_use]
863pub fn field(field: impl Into<String>) -> OrderExpr {
864    OrderExpr::field(field)
865}
866
867/// Build one ascending typed ORDER BY term.
868#[must_use]
869pub fn asc(expr: impl Into<OrderExpr>) -> OrderTerm {
870    OrderTerm::asc(expr)
871}
872
873/// Build one descending typed ORDER BY term.
874#[must_use]
875pub fn desc(expr: impl Into<OrderExpr>) -> OrderTerm {
876    OrderTerm::desc(expr)
877}
878
879///
880/// TESTS
881///
882
883#[cfg(test)]
884mod tests {
885    use super::{FilterExpr, FilterValue};
886    use crate::{
887        db::query::plan::expr::{BinaryOp, Expr, FieldId},
888        model::{EntityModel, field::FieldKind, field::FieldModel},
889        types::Ulid,
890        value::Value,
891    };
892    use candid::types::{CandidType, Label, Type, TypeInner};
893
894    static FILTER_TEST_FIELDS: [FieldModel; 3] = [
895        FieldModel::generated("id", FieldKind::Ulid),
896        FieldModel::generated("rank", FieldKind::Uint),
897        FieldModel::generated("active", FieldKind::Bool),
898    ];
899    static FILTER_TEST_MODEL: EntityModel = EntityModel::generated(
900        "tests::FilterEntity",
901        "FilterEntity",
902        &FILTER_TEST_FIELDS[0],
903        0,
904        &FILTER_TEST_FIELDS,
905        &[],
906    );
907
908    fn expect_record_fields(ty: Type) -> Vec<String> {
909        match ty.as_ref() {
910            TypeInner::Record(fields) => fields
911                .iter()
912                .map(|field| match field.id.as_ref() {
913                    Label::Named(name) => name.clone(),
914                    other => panic!("expected named record field, got {other:?}"),
915                })
916                .collect(),
917            other => panic!("expected candid record, got {other:?}"),
918        }
919    }
920
921    fn expect_variant_labels(ty: Type) -> Vec<String> {
922        match ty.as_ref() {
923            TypeInner::Variant(fields) => fields
924                .iter()
925                .map(|field| match field.id.as_ref() {
926                    Label::Named(name) => name.clone(),
927                    other => panic!("expected named variant label, got {other:?}"),
928                })
929                .collect(),
930            other => panic!("expected candid variant, got {other:?}"),
931        }
932    }
933
934    fn expect_variant_field_type(ty: Type, variant_name: &str) -> Type {
935        match ty.as_ref() {
936            TypeInner::Variant(fields) => fields
937                .iter()
938                .find_map(|field| match field.id.as_ref() {
939                    Label::Named(name) if name == variant_name => Some(field.ty.clone()),
940                    _ => None,
941                })
942                .unwrap_or_else(|| panic!("expected variant label `{variant_name}`")),
943            other => panic!("expected candid variant, got {other:?}"),
944        }
945    }
946
947    #[test]
948    fn filter_expr_eq_candid_payload_shape_is_stable() {
949        let fields = expect_record_fields(expect_variant_field_type(FilterExpr::ty(), "Eq"));
950
951        for field in ["field", "value"] {
952            assert!(
953                fields.iter().any(|candidate| candidate == field),
954                "Eq payload must keep `{field}` field key in Candid shape",
955            );
956        }
957    }
958
959    #[test]
960    fn filter_value_variant_labels_are_stable() {
961        let labels = expect_variant_labels(FilterValue::ty());
962
963        for label in ["String", "Bool", "Null", "List"] {
964            assert!(
965                labels.iter().any(|candidate| candidate == label),
966                "FilterValue must keep `{label}` variant label",
967            );
968        }
969    }
970
971    #[test]
972    fn filter_expr_and_candid_payload_shape_is_stable() {
973        match expect_variant_field_type(FilterExpr::ty(), "And").as_ref() {
974            TypeInner::Vec(_) => {}
975            other => panic!("And payload must remain a Candid vec payload, got {other:?}"),
976        }
977    }
978
979    #[test]
980    fn filter_expr_text_contains_ci_candid_payload_shape_is_stable() {
981        let fields = expect_record_fields(expect_variant_field_type(
982            FilterExpr::ty(),
983            "TextContainsCi",
984        ));
985
986        for field in ["field", "value"] {
987            assert!(
988                fields.iter().any(|candidate| candidate == field),
989                "TextContainsCi payload must keep `{field}` field key in Candid shape",
990            );
991        }
992    }
993
994    #[test]
995    fn filter_expr_not_payload_shape_is_stable() {
996        match expect_variant_field_type(FilterExpr::ty(), "Not").as_ref() {
997            TypeInner::Var(_) | TypeInner::Knot(_) | TypeInner::Variant(_) => {}
998            other => panic!("Not payload must keep nested predicate payload, got {other:?}"),
999        }
1000    }
1001
1002    #[test]
1003    fn filter_expr_variant_labels_are_stable() {
1004        let labels = expect_variant_labels(FilterExpr::ty());
1005
1006        for label in ["Eq", "And", "Not", "TextContainsCi", "IsMissing"] {
1007            assert!(
1008                labels.iter().any(|candidate| candidate == label),
1009                "FilterExpr must keep `{label}` variant label",
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn query_expr_fixture_constructors_stay_usable() {
1016        let expr = FilterExpr::and(vec![
1017            FilterExpr::is_null("deleted_at"),
1018            FilterExpr::not(FilterExpr::is_missing("name")),
1019        ]);
1020
1021        match expr {
1022            FilterExpr::And(items) => assert_eq!(items.len(), 2),
1023            other => panic!("expected And fixture, got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn filter_expr_model_lowering_rehydrates_string_ulid_literal() {
1029        let ulid = Ulid::default();
1030        let expr =
1031            FilterExpr::eq("id", ulid.to_string()).lower_bool_expr_for_model(&FILTER_TEST_MODEL);
1032
1033        assert_eq!(
1034            expr,
1035            Expr::Binary {
1036                op: BinaryOp::Eq,
1037                left: Box::new(Expr::Field(FieldId::new("id".to_string()))),
1038                right: Box::new(Expr::Literal(Value::Ulid(ulid))),
1039            }
1040        );
1041    }
1042
1043    #[test]
1044    fn filter_expr_model_lowering_rehydrates_numeric_membership_literals() {
1045        let expr = FilterExpr::in_list("rank", [7_u64, 9_u64])
1046            .lower_bool_expr_for_model(&FILTER_TEST_MODEL);
1047
1048        assert_eq!(
1049            expr,
1050            Expr::Binary {
1051                op: BinaryOp::Or,
1052                left: Box::new(Expr::Binary {
1053                    op: BinaryOp::Eq,
1054                    left: Box::new(Expr::Field(FieldId::new("rank".to_string()))),
1055                    right: Box::new(Expr::Literal(Value::Uint(7))),
1056                }),
1057                right: Box::new(Expr::Binary {
1058                    op: BinaryOp::Eq,
1059                    left: Box::new(Expr::Field(FieldId::new("rank".to_string()))),
1060                    right: Box::new(Expr::Literal(Value::Uint(9))),
1061                }),
1062            }
1063        );
1064    }
1065}