use crate::{
traits::{EntityKind, FieldValue},
value::Value,
};
use candid::CandidType;
use icydb_core::db::{
CoercionId, CompareOp, ComparePredicate, FilterExpr as CoreFilterExpr,
OrderDirection as CoreOrderDirection, Predicate, QueryError, SortExpr as CoreSortExpr,
};
use serde::Deserialize;
#[derive(CandidType, Clone, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum FilterExpr {
True,
False,
And(Vec<Self>),
Or(Vec<Self>),
Not(Box<Self>),
Eq {
field: String,
value: Value,
},
Ne {
field: String,
value: Value,
},
Lt {
field: String,
value: Value,
},
Lte {
field: String,
value: Value,
},
Gt {
field: String,
value: Value,
},
Gte {
field: String,
value: Value,
},
In {
field: String,
values: Vec<Value>,
},
NotIn {
field: String,
values: Vec<Value>,
},
Contains {
field: String,
value: Value,
},
TextContains {
field: String,
value: Value,
},
TextContainsCi {
field: String,
value: Value,
},
StartsWith {
field: String,
value: Value,
},
StartsWithCi {
field: String,
value: Value,
},
EndsWith {
field: String,
value: Value,
},
EndsWithCi {
field: String,
value: Value,
},
IsNull {
field: String,
},
IsNotNull {
field: String,
},
IsMissing {
field: String,
},
IsEmpty {
field: String,
},
IsNotEmpty {
field: String,
},
}
impl FilterExpr {
#[expect(clippy::too_many_lines)]
pub fn lower<E: EntityKind>(&self) -> Result<CoreFilterExpr, QueryError> {
let lower_pred =
|expr: &Self| -> Result<Predicate, QueryError> { Ok(expr.lower::<E>()?.0) };
let pred = match self {
Self::True => Predicate::True,
Self::False => Predicate::False,
Self::And(xs) => {
Predicate::and(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
}
Self::Or(xs) => {
Predicate::or(xs.iter().map(lower_pred).collect::<Result<Vec<_>, _>>()?)
}
Self::Not(x) => Predicate::not(lower_pred(x)?),
Self::Eq { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Eq,
value.clone(),
CoercionId::Strict,
)),
Self::Ne { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Ne,
value.clone(),
CoercionId::Strict,
)),
Self::Lt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Lt,
value.clone(),
CoercionId::NumericWiden,
)),
Self::Lte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Lte,
value.clone(),
CoercionId::NumericWiden,
)),
Self::Gt { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Gt,
value.clone(),
CoercionId::NumericWiden,
)),
Self::Gte { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Gte,
value.clone(),
CoercionId::NumericWiden,
)),
Self::In { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::In,
Value::List(values.clone()),
CoercionId::Strict,
)),
Self::NotIn { field, values } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::NotIn,
Value::List(values.clone()),
CoercionId::Strict,
)),
Self::Contains { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::Contains,
value.clone(),
CoercionId::Strict,
)),
Self::TextContains { field, value } => Predicate::TextContains {
field: field.clone(),
value: value.clone(),
},
Self::TextContainsCi { field, value } => Predicate::TextContainsCi {
field: field.clone(),
value: value.clone(),
},
Self::StartsWith { field, value } => {
Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::StartsWith,
value.clone(),
CoercionId::Strict,
))
}
Self::StartsWithCi { field, value } => {
Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::StartsWith,
value.clone(),
CoercionId::TextCasefold,
))
}
Self::EndsWith { field, value } => Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::EndsWith,
value.clone(),
CoercionId::Strict,
)),
Self::EndsWithCi { field, value } => {
Predicate::Compare(ComparePredicate::with_coercion(
field.as_str(),
CompareOp::EndsWith,
value.clone(),
CoercionId::TextCasefold,
))
}
Self::IsNull { field } => Predicate::IsNull {
field: field.clone(),
},
Self::IsNotNull { field } => Predicate::and(vec![
Predicate::not(Predicate::IsNull {
field: field.clone(),
}),
Predicate::not(Predicate::IsMissing {
field: field.clone(),
}),
]),
Self::IsMissing { field } => Predicate::IsMissing {
field: field.clone(),
},
Self::IsEmpty { field } => Predicate::IsEmpty {
field: field.clone(),
},
Self::IsNotEmpty { field } => Predicate::IsNotEmpty {
field: field.clone(),
},
};
Ok(CoreFilterExpr(pred))
}
#[must_use]
pub const fn and(exprs: Vec<Self>) -> Self {
Self::And(exprs)
}
#[must_use]
pub const fn or(exprs: Vec<Self>) -> Self {
Self::Or(exprs)
}
#[must_use]
#[expect(clippy::should_implement_trait)]
pub fn not(expr: Self) -> Self {
Self::Not(Box::new(expr))
}
pub fn eq(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Eq {
field: field.into(),
value: value.to_value(),
}
}
pub fn ne(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Ne {
field: field.into(),
value: value.to_value(),
}
}
pub fn lt(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Lt {
field: field.into(),
value: value.to_value(),
}
}
pub fn lte(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Lte {
field: field.into(),
value: value.to_value(),
}
}
pub fn gt(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Gt {
field: field.into(),
value: value.to_value(),
}
}
pub fn gte(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Gte {
field: field.into(),
value: value.to_value(),
}
}
pub fn in_list(
field: impl Into<String>,
values: impl IntoIterator<Item = impl FieldValue>,
) -> Self {
Self::In {
field: field.into(),
values: values.into_iter().map(|v| v.to_value()).collect(),
}
}
pub fn not_in(
field: impl Into<String>,
values: impl IntoIterator<Item = impl FieldValue>,
) -> Self {
Self::NotIn {
field: field.into(),
values: values.into_iter().map(|v| v.to_value()).collect(),
}
}
pub fn contains(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::Contains {
field: field.into(),
value: value.to_value(),
}
}
pub fn text_contains(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::TextContains {
field: field.into(),
value: value.to_value(),
}
}
pub fn text_contains_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::TextContainsCi {
field: field.into(),
value: value.to_value(),
}
}
pub fn starts_with(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::StartsWith {
field: field.into(),
value: value.to_value(),
}
}
pub fn starts_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::StartsWithCi {
field: field.into(),
value: value.to_value(),
}
}
pub fn ends_with(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::EndsWith {
field: field.into(),
value: value.to_value(),
}
}
pub fn ends_with_ci(field: impl Into<String>, value: impl FieldValue) -> Self {
Self::EndsWithCi {
field: field.into(),
value: value.to_value(),
}
}
pub fn is_null(field: impl Into<String>) -> Self {
Self::IsNull {
field: field.into(),
}
}
pub fn is_not_null(field: impl Into<String>) -> Self {
Self::IsNotNull {
field: field.into(),
}
}
pub fn is_missing(field: impl Into<String>) -> Self {
Self::IsMissing {
field: field.into(),
}
}
pub fn is_empty(field: impl Into<String>) -> Self {
Self::IsEmpty {
field: field.into(),
}
}
pub fn is_not_empty(field: impl Into<String>) -> Self {
Self::IsNotEmpty {
field: field.into(),
}
}
}
#[derive(CandidType, Clone, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SortExpr {
fields: Vec<(String, OrderDirection)>,
}
impl SortExpr {
#[must_use]
pub const fn new(fields: Vec<(String, OrderDirection)>) -> Self {
Self { fields }
}
#[must_use]
pub fn fields(&self) -> &[(String, OrderDirection)] {
&self.fields
}
#[must_use]
pub fn lower(&self) -> CoreSortExpr {
let fields = self
.fields()
.iter()
.map(|(field, dir)| {
let dir = match dir {
OrderDirection::Asc => CoreOrderDirection::Asc,
OrderDirection::Desc => CoreOrderDirection::Desc,
};
(field.clone(), dir)
})
.collect();
CoreSortExpr::new(fields)
}
}
#[derive(CandidType, Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum OrderDirection {
Asc,
Desc,
}
#[cfg(test)]
mod tests {
use super::{FilterExpr, OrderDirection, SortExpr};
use candid::types::{CandidType, Label, Type, TypeInner};
fn expect_record_fields(ty: Type) -> Vec<String> {
match ty.as_ref() {
TypeInner::Record(fields) => fields
.iter()
.map(|field| match field.id.as_ref() {
Label::Named(name) => name.clone(),
other => panic!("expected named record field, got {other:?}"),
})
.collect(),
other => panic!("expected candid record, got {other:?}"),
}
}
fn expect_variant_labels(ty: Type) -> Vec<String> {
match ty.as_ref() {
TypeInner::Variant(fields) => fields
.iter()
.map(|field| match field.id.as_ref() {
Label::Named(name) => name.clone(),
other => panic!("expected named variant label, got {other:?}"),
})
.collect(),
other => panic!("expected candid variant, got {other:?}"),
}
}
fn expect_variant_field_type(ty: Type, variant_name: &str) -> Type {
match ty.as_ref() {
TypeInner::Variant(fields) => fields
.iter()
.find_map(|field| match field.id.as_ref() {
Label::Named(name) if name == variant_name => Some(field.ty.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("expected variant label `{variant_name}`")),
other => panic!("expected candid variant, got {other:?}"),
}
}
#[test]
fn filter_expr_eq_candid_payload_shape_is_stable() {
let fields = expect_record_fields(expect_variant_field_type(FilterExpr::ty(), "Eq"));
for field in ["field", "value"] {
assert!(
fields.iter().any(|candidate| candidate == field),
"Eq payload must keep `{field}` field key in Candid shape",
);
}
}
#[test]
fn filter_expr_and_candid_payload_shape_is_stable() {
match expect_variant_field_type(FilterExpr::ty(), "And").as_ref() {
TypeInner::Vec(_) => {}
other => panic!("And payload must remain a Candid vec payload, got {other:?}"),
}
}
#[test]
fn sort_expr_candid_field_name_is_stable() {
let fields = expect_record_fields(SortExpr::ty());
assert!(
fields.iter().any(|candidate| candidate == "fields"),
"SortExpr must keep `fields` as Candid field key",
);
}
#[test]
fn order_direction_variant_labels_are_stable() {
let mut labels = expect_variant_labels(OrderDirection::ty());
labels.sort_unstable();
assert_eq!(labels, vec!["Asc".to_string(), "Desc".to_string()]);
}
#[test]
fn filter_expr_text_contains_ci_candid_payload_shape_is_stable() {
let fields = expect_record_fields(expect_variant_field_type(
FilterExpr::ty(),
"TextContainsCi",
));
for field in ["field", "value"] {
assert!(
fields.iter().any(|candidate| candidate == field),
"TextContainsCi payload must keep `{field}` field key in Candid shape",
);
}
}
#[test]
fn filter_expr_not_payload_shape_is_stable() {
match expect_variant_field_type(FilterExpr::ty(), "Not").as_ref() {
TypeInner::Var(_) | TypeInner::Knot(_) | TypeInner::Variant(_) => {}
other => panic!("Not payload must keep nested predicate payload, got {other:?}"),
}
}
#[test]
fn filter_expr_variant_labels_are_stable() {
let labels = expect_variant_labels(FilterExpr::ty());
for label in ["Eq", "And", "Not", "TextContainsCi", "IsMissing"] {
assert!(
labels.iter().any(|candidate| candidate == label),
"FilterExpr must keep `{label}` variant label",
);
}
}
#[test]
fn query_expr_fixture_constructors_stay_usable() {
let expr = FilterExpr::and(vec![
FilterExpr::is_null("deleted_at"),
FilterExpr::not(FilterExpr::is_missing("name")),
]);
let sort = SortExpr::new(vec![("created_at".to_string(), OrderDirection::Desc)]);
match expr {
FilterExpr::And(items) => assert_eq!(items.len(), 2),
other => panic!("expected And fixture, got {other:?}"),
}
assert_eq!(sort.fields().len(), 1);
assert!(matches!(sort.fields()[0].1, OrderDirection::Desc));
}
}