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