use sea_orm::sea_query::{Alias, Expr, ExprTrait, Func, SimpleExpr};
use sea_orm::{ColumnTrait, Value};
use serde_json::Value as JsonValue;
pub trait DjangoLookups: ColumnTrait {
fn exact<V>(&self, val: V) -> SimpleExpr
where
V: Into<Value>,
{
self.eq(val)
}
fn iexact<V>(&self, val: V) -> SimpleExpr
where
V: Into<String>,
{
Func::lower(Expr::col(self.as_column_ref())).eq(val.into().to_lowercase())
}
fn icontains<V>(&self, val: V) -> SimpleExpr
where
V: Into<String>,
{
let pattern = format!("%{}%", val.into().to_lowercase());
Func::lower(Expr::col(self.as_column_ref())).like(pattern)
}
fn istartswith<V>(&self, val: V) -> SimpleExpr
where
V: Into<String>,
{
let pattern = format!("{}%", val.into().to_lowercase());
Func::lower(Expr::col(self.as_column_ref())).like(pattern)
}
fn iendswith<V>(&self, val: V) -> SimpleExpr
where
V: Into<String>,
{
let pattern = format!("%{}", val.into().to_lowercase());
Func::lower(Expr::col(self.as_column_ref())).like(pattern)
}
fn in_list<V, I>(&self, values: I) -> SimpleExpr
where
V: Into<Value>,
I: IntoIterator<Item = V>,
{
self.is_in(values)
}
}
impl<T: ColumnTrait> DjangoLookups for T {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LookupType {
Exact,
IExact,
Contains,
IContains,
Gt,
Gte,
Lt,
Lte,
In,
StartsWith,
IStartsWith,
EndsWith,
IEndsWith,
Range,
IsNull,
Regex,
IRegex,
Year,
Month,
Day,
Hour,
Minute,
Second,
Week,
WeekDay,
Quarter,
HasKey,
HasKeys,
HasAnyKeys,
}
impl LookupType {
#[must_use]
pub fn parse(lookup: &str) -> (String, Self) {
let mut segments = lookup.rsplitn(2, "__");
let suffix = segments.next().unwrap_or(lookup);
match (segments.next(), Self::from_suffix(suffix)) {
(Some(field_path), Some(lookup_type)) if !field_path.is_empty() => {
(field_path.to_string(), lookup_type)
}
_ => (lookup.to_string(), Self::Exact),
}
}
fn from_suffix(suffix: &str) -> Option<Self> {
Some(match suffix {
"exact" => Self::Exact,
"iexact" => Self::IExact,
"contains" => Self::Contains,
"icontains" => Self::IContains,
"gt" => Self::Gt,
"gte" => Self::Gte,
"lt" => Self::Lt,
"lte" => Self::Lte,
"in" => Self::In,
"startswith" => Self::StartsWith,
"istartswith" => Self::IStartsWith,
"endswith" => Self::EndsWith,
"iendswith" => Self::IEndsWith,
"range" => Self::Range,
"isnull" => Self::IsNull,
"regex" => Self::Regex,
"iregex" => Self::IRegex,
"year" => Self::Year,
"month" => Self::Month,
"day" => Self::Day,
"hour" => Self::Hour,
"minute" => Self::Minute,
"second" => Self::Second,
"week" => Self::Week,
"week_day" | "weekday" => Self::WeekDay,
"quarter" => Self::Quarter,
"has_key" => Self::HasKey,
"has_keys" => Self::HasKeys,
"has_any_keys" => Self::HasAnyKeys,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct LookupBuilder<'a> {
column: &'a str,
lookup: LookupType,
value: &'a JsonValue,
}
impl<'a> LookupBuilder<'a> {
#[must_use]
pub fn new(column: &'a str, value: &'a JsonValue) -> Self {
Self {
column,
lookup: LookupType::Exact,
value,
}
}
#[must_use]
pub fn with_lookup(mut self, lookup: LookupType) -> Self {
self.lookup = lookup;
self
}
#[must_use]
pub fn build(self) -> SimpleExpr {
build_lookup(self.column, &self.lookup, self.value)
}
}
#[must_use]
pub fn lookup<'a>(column: &'a str, value: &'a JsonValue) -> LookupBuilder<'a> {
LookupBuilder::new(column, value)
}
#[must_use]
pub fn build_lookup(column: &str, lookup: &LookupType, value: &JsonValue) -> SimpleExpr {
let col = Expr::col(Alias::new(column));
match lookup {
LookupType::Exact => col.eq(json_to_value(value)),
LookupType::IExact => Func::lower(col).eq(lowered_string(value)),
LookupType::Contains => col.like(format!("%{}%", string_value(value))),
LookupType::IContains => Func::lower(col).like(format!("%{}%", lowered_string(value))),
LookupType::Gt => col.gt(json_to_value(value)),
LookupType::Gte => col.gte(json_to_value(value)),
LookupType::Lt => col.lt(json_to_value(value)),
LookupType::Lte => col.lte(json_to_value(value)),
LookupType::In => col.is_in(json_to_values(value)),
LookupType::StartsWith => col.like(format!("{}%", string_value(value))),
LookupType::IStartsWith => Func::lower(col).like(format!("{}%", lowered_string(value))),
LookupType::EndsWith => col.like(format!("%{}", string_value(value))),
LookupType::IEndsWith => Func::lower(col).like(format!("%{}", lowered_string(value))),
LookupType::Range => match range_bounds(value) {
Some((start, end)) => col.between(json_to_value(start), json_to_value(end)),
None => col.eq(json_to_value(value)),
},
LookupType::IsNull => {
if value.as_bool().unwrap_or(true) {
col.is_null()
} else {
col.is_not_null()
}
}
LookupType::Regex => Expr::cust_with_exprs("? REGEXP ?", vec![col, value_expr(value)]),
LookupType::IRegex => Expr::cust_with_exprs(
"? REGEXP ?",
vec![Func::lower(col).into(), Expr::val(lowered_string(value))],
),
LookupType::Year => component_lookup(Component::Year, column, value),
LookupType::Month => component_lookup(Component::Month, column, value),
LookupType::Day => component_lookup(Component::Day, column, value),
LookupType::Hour => component_lookup(Component::Hour, column, value),
LookupType::Minute => component_lookup(Component::Minute, column, value),
LookupType::Second => component_lookup(Component::Second, column, value),
LookupType::Week => component_lookup(Component::Week, column, value),
LookupType::WeekDay => weekday_lookup(column, value),
LookupType::Quarter => quarter_lookup(column, value),
LookupType::HasKey => json_has_key(column, &string_value(value)),
LookupType::HasKeys => all_json_keys_lookup(column, value),
LookupType::HasAnyKeys => any_json_keys_lookup(column, value),
}
}
enum Component {
Year,
Month,
Day,
Hour,
Minute,
Second,
Week,
}
fn component_lookup(component: Component, column: &str, value: &JsonValue) -> SimpleExpr {
let sql = match component {
Component::Year => "CAST(strftime('%Y', ?) AS INTEGER)",
Component::Month => "CAST(strftime('%m', ?) AS INTEGER)",
Component::Day => "CAST(strftime('%d', ?) AS INTEGER)",
Component::Hour => "CAST(strftime('%H', ?) AS INTEGER)",
Component::Minute => "CAST(strftime('%M', ?) AS INTEGER)",
Component::Second => "CAST(strftime('%S', ?) AS INTEGER)",
Component::Week => "CAST(strftime('%W', ?) AS INTEGER)",
};
Expr::cust_with_exprs(sql, [Expr::col(Alias::new(column))]).eq(json_to_value(value))
}
fn weekday_lookup(column: &str, value: &JsonValue) -> SimpleExpr {
Expr::cust_with_exprs(
"(CAST(strftime('%w', ?) AS INTEGER) + 1)",
[Expr::col(Alias::new(column))],
)
.eq(json_to_value(value))
}
fn quarter_lookup(column: &str, value: &JsonValue) -> SimpleExpr {
Expr::cust_with_exprs(
"(((CAST(strftime('%m', ?) AS INTEGER) - 1) / 3) + 1)",
[Expr::col(Alias::new(column))],
)
.eq(json_to_value(value))
}
fn json_has_key(column: &str, key: &str) -> SimpleExpr {
Expr::cust_with_exprs(
"json_type(?, ?) IS NOT NULL",
[Expr::col(Alias::new(column)), Expr::val(format!("$.{key}"))],
)
}
fn all_json_keys_lookup(column: &str, value: &JsonValue) -> SimpleExpr {
json_keys(value)
.into_iter()
.map(|key| json_has_key(column, &key))
.reduce(|lhs, rhs| lhs.and(rhs))
.unwrap_or_else(|| Expr::cust("1 = 1"))
}
fn any_json_keys_lookup(column: &str, value: &JsonValue) -> SimpleExpr {
json_keys(value)
.into_iter()
.map(|key| json_has_key(column, &key))
.reduce(|lhs, rhs| lhs.or(rhs))
.unwrap_or_else(|| Expr::cust("1 = 0"))
}
fn range_bounds(value: &JsonValue) -> Option<(&JsonValue, &JsonValue)> {
value.as_array().and_then(|values| match values.as_slice() {
[start, end] => Some((start, end)),
_ => None,
})
}
fn string_value(value: &JsonValue) -> String {
match value {
JsonValue::String(value) => value.clone(),
JsonValue::Null => String::new(),
_ => value.to_string(),
}
}
fn lowered_string(value: &JsonValue) -> String {
string_value(value).to_lowercase()
}
fn json_keys(value: &JsonValue) -> Vec<String> {
match value {
JsonValue::Array(values) => values
.iter()
.map(string_value)
.filter(|value| !value.is_empty())
.collect(),
_ => {
let key = string_value(value);
if key.is_empty() {
Vec::new()
} else {
vec![key]
}
}
}
}
fn value_expr(value: &JsonValue) -> SimpleExpr {
Expr::val(json_to_value(value))
}
fn json_to_value(value: &JsonValue) -> Value {
match value {
JsonValue::Null => Option::<String>::None.into(),
JsonValue::Bool(value) => (*value).into(),
JsonValue::Number(value) => {
if let Some(value) = value.as_i64() {
value.into()
} else if let Some(value) = value.as_u64() {
value.into()
} else if let Some(value) = value.as_f64() {
value.into()
} else {
value.to_string().into()
}
}
JsonValue::String(value) => value.clone().into(),
JsonValue::Array(_) | JsonValue::Object(_) => value.to_string().into(),
}
}
fn json_to_values(value: &JsonValue) -> Vec<Value> {
match value {
JsonValue::Array(values) => values.iter().map(json_to_value).collect(),
_ => vec![json_to_value(value)],
}
}
#[macro_export]
macro_rules! filter {
($columns:path, $($field:ident . $lookup:ident ( $($args:expr),* $(,)? )),+ $(,)?) => {{
use $crate::db::models::lookups::DjangoLookups as _;
use sea_orm::ColumnTrait as _;
let mut cond = sea_orm::Condition::all();
$(
cond = cond.add(<$columns>::$field.$lookup($($args),*));
)+
cond
}};
}
#[cfg(test)]
mod tests {
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::{Alias, Query, SimpleExpr, SqliteQueryBuilder};
use sea_orm::{Condition, DbBackend, QueryFilter, QueryTrait};
use serde_json::json;
use super::{DjangoLookups, LookupType, build_lookup, lookup};
mod article {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "articles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
}
impl ActiveModelBehavior for ActiveModel {}
}
fn render_where(expr: SimpleExpr) -> String {
Query::select()
.column(Alias::new("id"))
.from(Alias::new("widgets"))
.cond_where(Condition::all().add(expr))
.to_owned()
.to_string(SqliteQueryBuilder)
}
#[test]
fn exact_and_in_list_expand_to_seaorm_primitives() {
let sql = article::Entity::find()
.filter(article::Column::Title.exact("Rust"))
.filter(article::Column::Id.in_list([1, 2, 3]))
.build(DbBackend::Sqlite)
.to_string();
assert!(
sql.contains("\"articles\".\"title\" = 'Rust'"),
"unexpected SQL: {sql}"
);
assert!(
sql.contains("\"articles\".\"id\" IN (1, 2, 3)"),
"unexpected SQL: {sql}"
);
}
#[test]
fn case_insensitive_lookups_lower_the_column_and_value() {
let sql = article::Entity::find()
.filter(article::Column::Title.iexact("RuSt"))
.filter(article::Column::Title.icontains("Us"))
.filter(article::Column::Title.istartswith("Ru"))
.filter(article::Column::Title.iendswith("St"))
.build(DbBackend::Sqlite)
.to_string();
assert!(sql.contains("LOWER"), "unexpected SQL: {sql}");
assert!(
sql.contains("= 'rust'"),
"iexact should lower the value: {sql}"
);
assert!(
sql.contains("LIKE '%us%'"),
"icontains should lower the pattern: {sql}"
);
assert!(
sql.contains("LIKE 'ru%'"),
"istartswith should lower the pattern: {sql}"
);
assert!(
sql.contains("LIKE '%st'"),
"iendswith should lower the pattern: {sql}"
);
}
#[test]
fn filter_macro_supports_native_and_django_style_lookups() {
let sql = article::Entity::find()
.filter(crate::filter!(
article::Column,
Title.icontains("rust"),
Id.eq(1)
))
.build(DbBackend::Sqlite)
.to_string();
assert!(sql.contains("LOWER"), "unexpected SQL: {sql}");
assert!(sql.contains("LIKE '%rust%'"), "unexpected SQL: {sql}");
assert!(
sql.contains("\"articles\".\"id\" = 1"),
"unexpected SQL: {sql}"
);
}
#[test]
fn parse_simple_lookup() {
assert_eq!(
LookupType::parse("name__exact"),
("name".to_string(), LookupType::Exact)
);
}
#[test]
fn parse_double_underscore() {
assert_eq!(
LookupType::parse("user__name__icontains"),
("user__name".to_string(), LookupType::IContains)
);
}
#[test]
fn parse_no_lookup() {
assert_eq!(
LookupType::parse("name"),
("name".to_string(), LookupType::Exact)
);
}
#[test]
fn build_exact_lookup() {
let sql = render_where(build_lookup("name", &LookupType::Exact, &json!("rust")));
assert!(sql.contains("\"name\" = 'rust'"), "unexpected SQL: {sql}");
}
#[test]
fn build_contains_lookup() {
let sql = render_where(build_lookup("name", &LookupType::Contains, &json!("rust")));
assert!(sql.contains("LIKE '%rust%'"), "unexpected SQL: {sql}");
}
#[test]
fn build_gt_lookup() {
let sql = render_where(build_lookup("age", &LookupType::Gt, &json!(18)));
assert!(sql.contains("\"age\" > 18"), "unexpected SQL: {sql}");
}
#[test]
fn build_in_lookup() {
let sql = render_where(build_lookup("id", &LookupType::In, &json!([1, 2, 3])));
assert!(sql.contains("\"id\" IN (1, 2, 3)"), "unexpected SQL: {sql}");
}
#[test]
fn build_is_null_lookup() {
let sql = render_where(build_lookup(
"deleted_at",
&LookupType::IsNull,
&json!(true),
));
assert!(
sql.contains("\"deleted_at\" IS NULL"),
"unexpected SQL: {sql}"
);
}
#[test]
fn build_range_lookup() {
let sql = render_where(build_lookup("score", &LookupType::Range, &json!([10, 20])));
assert!(sql.contains("BETWEEN 10 AND 20"), "unexpected SQL: {sql}");
}
#[test]
fn lookup_builder_defaults_to_exact() {
let sql = render_where(lookup("title", &json!("Rust")).build());
assert!(sql.contains("\"title\" = 'Rust'"), "unexpected SQL: {sql}");
}
}