1use crate::{
2 traits::{EntityKind, FieldValue},
3 value::Value,
4};
5use candid::CandidType;
6use icydb_core::db::{
7 CoercionId, CompareOp, ComparePredicate, FilterExpr as CoreFilterExpr,
8 OrderDirection as CoreOrderDirection, Predicate, QueryError, SortExpr as CoreSortExpr,
9};
10use serde::Deserialize;
11
12#[derive(CandidType, Clone, Debug, Deserialize)]
27#[serde(rename_all = "PascalCase")]
28pub enum FilterExpr {
29 True,
31 False,
33
34 And(Vec<Self>),
35 Or(Vec<Self>),
36 Not(Box<Self>),
37
38 Eq {
42 field: String,
43 value: Value,
44 },
45 Ne {
46 field: String,
47 value: Value,
48 },
49 Lt {
50 field: String,
51 value: Value,
52 },
53 Lte {
54 field: String,
55 value: Value,
56 },
57 Gt {
58 field: String,
59 value: Value,
60 },
61 Gte {
62 field: String,
63 value: Value,
64 },
65
66 In {
67 field: String,
68 values: Vec<Value>,
69 },
70 NotIn {
71 field: String,
72 values: Vec<Value>,
73 },
74
75 Contains {
80 field: String,
81 value: Value,
82 },
83
84 TextContains {
89 field: String,
90 value: Value,
91 },
92
93 TextContainsCi {
95 field: String,
96 value: Value,
97 },
98
99 StartsWith {
100 field: String,
101 value: Value,
102 },
103 StartsWithCi {
104 field: String,
105 value: Value,
106 },
107
108 EndsWith {
109 field: String,
110 value: Value,
111 },
112 EndsWithCi {
113 field: String,
114 value: Value,
115 },
116
117 IsNull {
122 field: String,
123 },
124
125 IsNotNull {
128 field: String,
129 },
130
131 IsMissing {
133 field: String,
134 },
135
136 IsEmpty {
138 field: String,
139 },
140
141 IsNotEmpty {
143 field: String,
144 },
145}
146
147impl FilterExpr {
148 #[expect(clippy::too_many_lines)]
156 pub fn lower<E: EntityKind>(&self) -> Result<CoreFilterExpr, QueryError> {
157 let lower_pred =
158 |expr: &Self| -> Result<Predicate, QueryError> { Ok(expr.lower::<E>()?.0) };
159
160 let pred = match self {
161 Self::True => Predicate::True,
162 Self::False => Predicate::False,
163
164 Self::And(xs) => {
165 Predicate::and(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
166 }
167 Self::Or(xs) => {
168 Predicate::or(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
169 }
170 Self::Not(x) => Predicate::not(lower_pred(x)?),
171
172 Self::Eq { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
173 field.as_str(),
174 CompareOp::Eq,
175 value.clone(),
176 CoercionId::Strict,
177 )),
178
179 Self::Ne { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
180 field.as_str(),
181 CompareOp::Ne,
182 value.clone(),
183 CoercionId::Strict,
184 )),
185
186 Self::Lt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
187 field.as_str(),
188 CompareOp::Lt,
189 value.clone(),
190 CoercionId::NumericWiden,
191 )),
192
193 Self::Lte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
194 field.as_str(),
195 CompareOp::Lte,
196 value.clone(),
197 CoercionId::NumericWiden,
198 )),
199
200 Self::Gt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
201 field.as_str(),
202 CompareOp::Gt,
203 value.clone(),
204 CoercionId::NumericWiden,
205 )),
206
207 Self::Gte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
208 field.as_str(),
209 CompareOp::Gte,
210 value.clone(),
211 CoercionId::NumericWiden,
212 )),
213
214 Self::In { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
215 field.as_str(),
216 CompareOp::In,
217 Value::List(values.clone()),
218 CoercionId::Strict,
219 )),
220
221 Self::NotIn { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
222 field.as_str(),
223 CompareOp::NotIn,
224 Value::List(values.clone()),
225 CoercionId::Strict,
226 )),
227
228 Self::Contains { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
229 field.as_str(),
230 CompareOp::Contains,
231 value.clone(),
232 CoercionId::Strict,
233 )),
234
235 Self::TextContains { field, value } => Predicate::TextContains {
236 field: field.clone(),
237 value: value.clone(),
238 },
239
240 Self::TextContainsCi { field, value } => Predicate::TextContainsCi {
241 field: field.clone(),
242 value: value.clone(),
243 },
244
245 Self::StartsWith { field, value } => {
246 Predicate::Compare(ComparePredicate::with_coercion(
247 field.as_str(),
248 CompareOp::StartsWith,
249 value.clone(),
250 CoercionId::Strict,
251 ))
252 }
253
254 Self::StartsWithCi { field, value } => {
255 Predicate::Compare(ComparePredicate::with_coercion(
256 field.as_str(),
257 CompareOp::StartsWith,
258 value.clone(),
259 CoercionId::TextCasefold,
260 ))
261 }
262
263 Self::EndsWith { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
264 field.as_str(),
265 CompareOp::EndsWith,
266 value.clone(),
267 CoercionId::Strict,
268 )),
269
270 Self::EndsWithCi { field, value } => {
271 Predicate::Compare(ComparePredicate::with_coercion(
272 field.as_str(),
273 CompareOp::EndsWith,
274 value.clone(),
275 CoercionId::TextCasefold,
276 ))
277 }
278
279 Self::IsNull { field } => Predicate::IsNull {
280 field: field.clone(),
281 },
282
283 Self::IsNotNull { field } => Predicate::and(vec![
284 Predicate::not(Predicate::IsNull {
285 field: field.clone(),
286 }),
287 Predicate::not(Predicate::IsMissing {
288 field: field.clone(),
289 }),
290 ]),
291
292 Self::IsMissing { field } => Predicate::IsMissing {
293 field: field.clone(),
294 },
295
296 Self::IsEmpty { field } => Predicate::IsEmpty {
297 field: field.clone(),
298 },
299
300 Self::IsNotEmpty { field } => Predicate::IsNotEmpty {
301 field: field.clone(),
302 },
303 };
304
305 Ok(CoreFilterExpr(pred))
306 }
307
308 #[must_use]
314 pub const fn and(exprs: Vec<Self>) -> Self {
315 Self::And(exprs)
316 }
317
318 #[must_use]
320 pub const fn or(exprs: Vec<Self>) -> Self {
321 Self::Or(exprs)
322 }
323
324 #[must_use]
326 #[expect(clippy::should_implement_trait)]
327 pub fn not(expr: Self) -> Self {
328 Self::Not(Box::new(expr))
329 }
330
331 pub fn eq(field: impl Into<String>, value: impl FieldValue) -> Self {
337 Self::Eq {
338 field: field.into(),
339 value: value.to_value(),
340 }
341 }
342
343 pub fn ne(field: impl Into<String>, value: impl FieldValue) -> Self {
345 Self::Ne {
346 field: field.into(),
347 value: value.to_value(),
348 }
349 }
350
351 pub fn lt(field: impl Into<String>, value: impl FieldValue) -> Self {
353 Self::Lt {
354 field: field.into(),
355 value: value.to_value(),
356 }
357 }
358
359 pub fn lte(field: impl Into<String>, value: impl FieldValue) -> Self {
361 Self::Lte {
362 field: field.into(),
363 value: value.to_value(),
364 }
365 }
366
367 pub fn gt(field: impl Into<String>, value: impl FieldValue) -> Self {
369 Self::Gt {
370 field: field.into(),
371 value: value.to_value(),
372 }
373 }
374
375 pub fn gte(field: impl Into<String>, value: impl FieldValue) -> Self {
377 Self::Gte {
378 field: field.into(),
379 value: value.to_value(),
380 }
381 }
382
383 pub fn in_list(
385 field: impl Into<String>,
386 values: impl IntoIterator<Item = impl FieldValue>,
387 ) -> Self {
388 Self::In {
389 field: field.into(),
390 values: values.into_iter().map(|v| v.to_value()).collect(),
391 }
392 }
393
394 pub fn not_in(
396 field: impl Into<String>,
397 values: impl IntoIterator<Item = impl FieldValue>,
398 ) -> Self {
399 Self::NotIn {
400 field: field.into(),
401 values: values.into_iter().map(|v| v.to_value()).collect(),
402 }
403 }
404
405 pub fn contains(field: impl Into<String>, value: impl FieldValue) -> Self {
411 Self::Contains {
412 field: field.into(),
413 value: value.to_value(),
414 }
415 }
416
417 pub fn text_contains(field: impl Into<String>, value: impl FieldValue) -> Self {
423 Self::TextContains {
424 field: field.into(),
425 value: value.to_value(),
426 }
427 }
428
429 pub fn text_contains_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
431 Self::TextContainsCi {
432 field: field.into(),
433 value: value.to_value(),
434 }
435 }
436
437 pub fn starts_with(field: impl Into<String>, value: impl FieldValue) -> Self {
439 Self::StartsWith {
440 field: field.into(),
441 value: value.to_value(),
442 }
443 }
444
445 pub fn starts_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
447 Self::StartsWithCi {
448 field: field.into(),
449 value: value.to_value(),
450 }
451 }
452
453 pub fn ends_with(field: impl Into<String>, value: impl FieldValue) -> Self {
455 Self::EndsWith {
456 field: field.into(),
457 value: value.to_value(),
458 }
459 }
460
461 pub fn ends_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
463 Self::EndsWithCi {
464 field: field.into(),
465 value: value.to_value(),
466 }
467 }
468
469 pub fn is_null(field: impl Into<String>) -> Self {
475 Self::IsNull {
476 field: field.into(),
477 }
478 }
479
480 pub fn is_not_null(field: impl Into<String>) -> Self {
482 Self::IsNotNull {
483 field: field.into(),
484 }
485 }
486
487 pub fn is_missing(field: impl Into<String>) -> Self {
489 Self::IsMissing {
490 field: field.into(),
491 }
492 }
493
494 pub fn is_empty(field: impl Into<String>) -> Self {
496 Self::IsEmpty {
497 field: field.into(),
498 }
499 }
500
501 pub fn is_not_empty(field: impl Into<String>) -> Self {
503 Self::IsNotEmpty {
504 field: field.into(),
505 }
506 }
507}
508
509#[derive(CandidType, Clone, Debug, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub struct SortExpr {
516 fields: Vec<(String, OrderDirection)>,
517}
518
519impl SortExpr {
520 #[must_use]
522 pub const fn new(fields: Vec<(String, OrderDirection)>) -> Self {
523 Self { fields }
524 }
525
526 #[must_use]
528 pub fn fields(&self) -> &[(String, OrderDirection)] {
529 &self.fields
530 }
531
532 #[must_use]
534 pub fn lower(&self) -> CoreSortExpr {
535 let fields = self
536 .fields()
537 .iter()
538 .map(|(field, dir)| {
539 let dir = match dir {
540 OrderDirection::Asc => CoreOrderDirection::Asc,
541 OrderDirection::Desc => CoreOrderDirection::Desc,
542 };
543 (field.clone(), dir)
544 })
545 .collect();
546
547 CoreSortExpr::new(fields)
548 }
549}
550
551#[derive(CandidType, Clone, Copy, Debug, Deserialize)]
556#[serde(rename_all = "PascalCase")]
557pub enum OrderDirection {
558 Asc,
559 Desc,
560}
561
562#[cfg(test)]
567mod tests {
568 use super::{FilterExpr, OrderDirection, SortExpr};
569 use candid::types::{CandidType, Label, Type, TypeInner};
570
571 fn expect_record_fields(ty: Type) -> Vec<String> {
572 match ty.as_ref() {
573 TypeInner::Record(fields) => fields
574 .iter()
575 .map(|field| match field.id.as_ref() {
576 Label::Named(name) => name.clone(),
577 other => panic!("expected named record field, got {other:?}"),
578 })
579 .collect(),
580 other => panic!("expected candid record, got {other:?}"),
581 }
582 }
583
584 fn expect_variant_labels(ty: Type) -> Vec<String> {
585 match ty.as_ref() {
586 TypeInner::Variant(fields) => fields
587 .iter()
588 .map(|field| match field.id.as_ref() {
589 Label::Named(name) => name.clone(),
590 other => panic!("expected named variant label, got {other:?}"),
591 })
592 .collect(),
593 other => panic!("expected candid variant, got {other:?}"),
594 }
595 }
596
597 fn expect_variant_field_type(ty: Type, variant_name: &str) -> Type {
598 match ty.as_ref() {
599 TypeInner::Variant(fields) => fields
600 .iter()
601 .find_map(|field| match field.id.as_ref() {
602 Label::Named(name) if name == variant_name => Some(field.ty.clone()),
603 _ => None,
604 })
605 .unwrap_or_else(|| panic!("expected variant label `{variant_name}`")),
606 other => panic!("expected candid variant, got {other:?}"),
607 }
608 }
609
610 #[test]
611 fn filter_expr_eq_candid_payload_shape_is_stable() {
612 let fields = expect_record_fields(expect_variant_field_type(FilterExpr::ty(), "Eq"));
613
614 for field in ["field", "value"] {
615 assert!(
616 fields.iter().any(|candidate| candidate == field),
617 "Eq payload must keep `{field}` field key in Candid shape",
618 );
619 }
620 }
621
622 #[test]
623 fn filter_expr_and_candid_payload_shape_is_stable() {
624 match expect_variant_field_type(FilterExpr::ty(), "And").as_ref() {
625 TypeInner::Vec(_) => {}
626 other => panic!("And payload must remain a Candid vec payload, got {other:?}"),
627 }
628 }
629
630 #[test]
631 fn sort_expr_candid_field_name_is_stable() {
632 let fields = expect_record_fields(SortExpr::ty());
633
634 assert!(
635 fields.iter().any(|candidate| candidate == "fields"),
636 "SortExpr must keep `fields` as Candid field key",
637 );
638 }
639
640 #[test]
641 fn order_direction_variant_labels_are_stable() {
642 let mut labels = expect_variant_labels(OrderDirection::ty());
643 labels.sort_unstable();
644 assert_eq!(labels, vec!["Asc".to_string(), "Desc".to_string()]);
645 }
646
647 #[test]
648 fn filter_expr_text_contains_ci_candid_payload_shape_is_stable() {
649 let fields = expect_record_fields(expect_variant_field_type(
650 FilterExpr::ty(),
651 "TextContainsCi",
652 ));
653
654 for field in ["field", "value"] {
655 assert!(
656 fields.iter().any(|candidate| candidate == field),
657 "TextContainsCi payload must keep `{field}` field key in Candid shape",
658 );
659 }
660 }
661
662 #[test]
663 fn filter_expr_not_payload_shape_is_stable() {
664 match expect_variant_field_type(FilterExpr::ty(), "Not").as_ref() {
665 TypeInner::Var(_) | TypeInner::Knot(_) | TypeInner::Variant(_) => {}
666 other => panic!("Not payload must keep nested predicate payload, got {other:?}"),
667 }
668 }
669
670 #[test]
671 fn filter_expr_variant_labels_are_stable() {
672 let labels = expect_variant_labels(FilterExpr::ty());
673
674 for label in ["Eq", "And", "Not", "TextContainsCi", "IsMissing"] {
675 assert!(
676 labels.iter().any(|candidate| candidate == label),
677 "FilterExpr must keep `{label}` variant label",
678 );
679 }
680 }
681
682 #[test]
683 fn query_expr_fixture_constructors_stay_usable() {
684 let expr = FilterExpr::and(vec![
685 FilterExpr::is_null("deleted_at"),
686 FilterExpr::not(FilterExpr::is_missing("name")),
687 ]);
688 let sort = SortExpr::new(vec![("created_at".to_string(), OrderDirection::Desc)]);
689
690 match expr {
691 FilterExpr::And(items) => assert_eq!(items.len(), 2),
692 other => panic!("expected And fixture, got {other:?}"),
693 }
694
695 assert_eq!(sort.fields().len(), 1);
696 assert!(matches!(sort.fields()[0].1, OrderDirection::Desc));
697 }
698}