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::{model::EntityModel, traits::FieldValue, value::Value};
16use candid::CandidType;
17use serde::Deserialize;
18
19#[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 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 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#[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 #[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 #[must_use]
342 pub const fn and(exprs: Vec<Self>) -> Self {
343 Self::And(exprs)
344 }
345
346 #[must_use]
348 pub const fn or(exprs: Vec<Self>) -> Self {
349 Self::Or(exprs)
350 }
351
352 #[must_use]
354 #[expect(clippy::should_implement_trait)]
355 pub fn not(expr: Self) -> Self {
356 Self::Not(Box::new(expr))
357 }
358
359 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
565 pub fn is_null(field: impl Into<String>) -> Self {
566 Self::IsNull {
567 field: field.into(),
568 }
569 }
570
571 #[must_use]
573 pub fn is_not_null(field: impl Into<String>) -> Self {
574 Self::IsNotNull {
575 field: field.into(),
576 }
577 }
578
579 #[must_use]
581 pub fn is_missing(field: impl Into<String>) -> Self {
582 Self::IsMissing {
583 field: field.into(),
584 }
585 }
586
587 #[must_use]
589 pub fn is_empty(field: impl Into<String>) -> Self {
590 Self::IsEmpty {
591 field: field.into(),
592 }
593 }
594
595 #[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#[derive(Clone, Debug, Eq, PartialEq)]
745pub struct OrderExpr {
746 expr: Expr,
747}
748
749impl OrderExpr {
750 #[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 const fn new(expr: Expr) -> Self {
764 Self { expr }
765 }
766
767 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#[derive(Clone, Debug, Eq, PartialEq)]
825pub struct OrderTerm {
826 expr: OrderExpr,
827 direction: OrderDirection,
828}
829
830impl OrderTerm {
831 #[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 #[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 pub(in crate::db) fn lower(&self) -> PlannedOrderTerm {
852 self.expr.lower(self.direction)
853 }
854}
855
856#[must_use]
858pub fn field(field: impl Into<String>) -> OrderExpr {
859 OrderExpr::field(field)
860}
861
862#[must_use]
864pub fn asc(expr: impl Into<OrderExpr>) -> OrderTerm {
865 OrderTerm::asc(expr)
866}
867
868#[must_use]
870pub fn desc(expr: impl Into<OrderExpr>) -> OrderTerm {
871 OrderTerm::desc(expr)
872}
873
874#[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}