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