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