1use 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#[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 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 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#[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 #[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 #[must_use]
349 pub const fn and(exprs: Vec<Self>) -> Self {
350 Self::And(exprs)
351 }
352
353 #[must_use]
355 pub const fn or(exprs: Vec<Self>) -> Self {
356 Self::Or(exprs)
357 }
358
359 #[must_use]
361 #[expect(clippy::should_implement_trait)]
362 pub fn not(expr: Self) -> Self {
363 Self::Not(Box::new(expr))
364 }
365
366 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
572 pub fn is_null(field: impl Into<String>) -> Self {
573 Self::IsNull {
574 field: field.into(),
575 }
576 }
577
578 #[must_use]
580 pub fn is_not_null(field: impl Into<String>) -> Self {
581 Self::IsNotNull {
582 field: field.into(),
583 }
584 }
585
586 #[must_use]
588 pub fn is_missing(field: impl Into<String>) -> Self {
589 Self::IsMissing {
590 field: field.into(),
591 }
592 }
593
594 #[must_use]
596 pub fn is_empty(field: impl Into<String>) -> Self {
597 Self::IsEmpty {
598 field: field.into(),
599 }
600 }
601
602 #[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#[derive(Clone, Debug, Eq, PartialEq)]
752pub struct OrderExpr {
753 expr: Expr,
754}
755
756impl OrderExpr {
757 #[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 const fn new(expr: Expr) -> Self {
771 Self { expr }
772 }
773
774 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#[derive(Clone, Debug, Eq, PartialEq)]
832pub struct OrderTerm {
833 expr: OrderExpr,
834 direction: OrderDirection,
835}
836
837impl OrderTerm {
838 #[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 #[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 pub(in crate::db) fn lower(&self) -> PlannedOrderTerm {
859 self.expr.lower(self.direction)
860 }
861}
862
863#[must_use]
865pub fn field(field: impl Into<String>) -> OrderExpr {
866 OrderExpr::field(field)
867}
868
869#[must_use]
871pub fn asc(expr: impl Into<OrderExpr>) -> OrderTerm {
872 OrderTerm::asc(expr)
873}
874
875#[must_use]
877pub fn desc(expr: impl Into<OrderExpr>) -> OrderTerm {
878 OrderTerm::desc(expr)
879}
880
881#[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}