From eabc5b18c1e1189e43ba284b99e6a66155befbe1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 2 Jul 2026 03:19:52 +0000 Subject: [PATCH 01/24] .contains, .startwith, .endswith proof of concept --- cot-macros/src/query.rs | 102 ++++++++++++++++++++++++++++++++++++---- cot/src/db/query.rs | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 8 deletions(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index 70d1c8846..6f0377f94 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -23,6 +23,33 @@ impl Parse for Query { } } +#[derive(Debug, Clone, Copy)] +pub enum StringMethod{ + Contains, + StartsWith, + EndsWith +} + +impl StringMethod { + pub fn from_ident(ident: &syn::Ident) -> Option{ + match ident.to_string().as_str() { + "contains" => Some(Self::Contains), + "starts_with" => Some(Self::StartsWith), + "ends_with" => Some(Self::EndsWith), + _ => None + } + } + + pub fn as_ident(&self) -> syn::Ident { + match self{ + Self::Contains => {format_ident!("contains")} + Self::StartsWith => {format_ident!("starts_with")} + Self::EndsWith => {format_ident!("ends_with")} + } + } +} + + pub(super) fn query_to_tokens(query: Query) -> TokenStream { let crate_name = cot_ident(); let model_name = query.model_name; @@ -70,15 +97,32 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream ) .to_compile_error(), }, - Expr::FunctionCall { function, args } => match function.as_tokens() { - Some(tokens) => { - quote!(#crate_name::db::query::Expr::value(#tokens(#(#args),*))) + Expr::FunctionCall { function, args } => { + let field_free_tokens = function.as_tokens(); + + if field_free_tokens.is_none() { + if let Expr::MemberAccess { member_name, .. } = &*function { + if let Some(method) = StringMethod::from_ident(member_name) { + let Expr::MemberAccess { parent, ..} = *function else { + unreachable!("function call must have a parent"); + }; + return handle_string_method(model_name, *parent, args, method); + } + } + } + + match field_free_tokens { + Some(tokens) => { + quote!(#crate_name::db::query::Expr::value(#tokens(#(#args),*))) + } + None => syn::Error::new_spanned( + function.as_tokens_full(), + "calling functions that reference database fields is unsupported \ + (only `.contains(..)`, `.starts_with(..)`, and `.ends_with(..)` \ + are supported directly on database fields)", + ) + .to_compile_error(), } - None => syn::Error::new_spanned( - function.as_tokens_full(), - "calling functions that reference database fields is unsupported", - ) - .to_compile_error(), }, Expr::And(lhs, rhs) => { let lhs = expr_to_tokens(model_name, *lhs); @@ -124,3 +168,45 @@ fn handle_binary_comparison( let rhs = expr_to_tokens(model_name, rhs); quote!(#crate_name::db::query::Expr::#bin_fn(#lhs, #rhs)) } + + +fn handle_string_method( + model_name: &syn::Type, + receiver: Expr, + args: Vec, + method: StringMethod, +) -> TokenStream { + let crate_name = cot_ident(); + let method_ident = method.as_ident(); + + let arg = match <[syn::Expr; 1]>::try_from(args) { + Ok([arg]) => arg, + Err(args) => { + let span = args + .first() + .map_or_else(proc_macro2::Span::call_site, syn::spanned::Spanned::span); + return syn::Error::new( + span, + format!("`{method_ident}` expects exactly one string argument"), + ) + .to_compile_error(); + } + }; + + if let Expr::FieldRef { ref field_name, .. } = receiver { + return quote! { + #crate_name::db::query::ExprLike::#method_ident( + <#model_name as #crate_name::db::Model>::Fields::#field_name, + #arg + ) + }; + } + + let receiver_tokens = expr_to_tokens(model_name, receiver); + quote! { + #crate_name::db::query::Expr::#method_ident( + #receiver_tokens, + #crate_name::db::query::Expr::value(#arg) + ) + } +} diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index b3283d95e..6b0b8222b 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -595,6 +595,9 @@ pub enum Expr { /// ); /// ``` Div(Box, Box), + Contains(Box, Box), + StartsWith(Box, Box), + EndsWith(Box, Box) } impl Expr { @@ -984,6 +987,21 @@ impl Expr { Self::Div(Box::new(lhs), Box::new(rhs)) } + #[must_use] + pub fn contains(lhs: Self, rhs: Self) -> Self { + Self::Contains(Box::new(lhs), Box::new(rhs)) + } + + #[must_use] + pub fn starts_with(lhs: Self, rhs: Self) -> Self { + Self::StartsWith(Box::new(lhs), Box::new(rhs)) + } + + #[must_use] + pub fn ends_with(lhs: Self, rhs: Self) -> Self { + Self::EndsWith(Box::new(lhs), Box::new(rhs)) + } + /// Returns the expression as a [`sea_query::SimpleExpr`]. /// /// # Example @@ -1020,6 +1038,9 @@ impl Expr { Self::Sub(lhs, rhs) => lhs.as_sea_query_expr().sub(rhs.as_sea_query_expr()), Self::Mul(lhs, rhs) => lhs.as_sea_query_expr().mul(rhs.as_sea_query_expr()), Self::Div(lhs, rhs) => lhs.as_sea_query_expr().div(rhs.as_sea_query_expr()), + Self::Contains(lhs, rhs) => like_expr(lhs, rhs, LikeMode::Contains), + Self::StartsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::StartsWith), + Self::EndsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::EndsWith) } } } @@ -1335,6 +1356,67 @@ impl ExprOrd for FieldRef { } } +pub trait ExprLike{ + fn contains>(self, other: V) -> Expr; + fn starts_with>(self, other: V) -> Expr; + fn ends_with>(self, other: V) -> Expr; +} + +impl ExprLike for FieldRef { + fn contains>(self, other: V) -> Expr { + Expr::contains(self.as_expr(), Expr::value(other.into_field())) + } + fn starts_with>(self, other: V) -> Expr { + Expr::starts_with(self.as_expr(), Expr::value(other.into_field())) + } + fn ends_with>(self, other: V) -> Expr { + Expr::ends_with(self.as_expr(), Expr::value(other.into_field())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum LikeMode{ + Contains, + StartsWith, + EndsWith, +} + +const LIKE_ESCAPE_CHAR: char = '\\'; + +fn escape_like_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for c in value.chars() { + if matches!(c, '%' | '_' | LIKE_ESCAPE_CHAR){ + escaped.push(LIKE_ESCAPE_CHAR) + } + escaped.push(c); + } + escaped +} +fn build_like_pattern(value: &str, mode: LikeMode) -> sea_query::LikeExpr { + let escaped = escape_like_value(value); + let pattern = match mode { + LikeMode::Contains => format!("%{escaped}%"), + LikeMode::StartsWith => format!("%{escaped}"), + LikeMode::EndsWith => format!("{escaped}%") + }; + sea_query::LikeExpr::new(pattern).escape(LIKE_ESCAPE_CHAR) +} + +fn like_expr(lhs: &Expr, rhs: &Expr, mode: LikeMode) -> sea_query::SimpleExpr { + let lhs_expr = lhs.as_sea_query_expr(); + + let Expr::Value(value) = rhs else { + return sea_query::Expr::val(1).eq(0); + }; + + let sea_value: sea_query::Value = value.clone().into(); + match sea_value { + sea_query::Value::String(Some(s)) => lhs_expr.like(build_like_pattern(&s, mode)), + _ => sea_query::Expr::val(1).eq(0) + } +} + macro_rules! impl_expr { ($ty:ty, $trait:ident, $method:ident) => { impl $trait<$ty> for FieldRef<$ty> { From e4b54d36476b85b762e39ed80f0e252507c24e00 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:22:58 +0000 Subject: [PATCH 02/24] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot-macros/src/query.rs | 34 +++++++++++++++++++--------------- cot/src/db/query.rs | 14 +++++++------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index 6f0377f94..d54fde118 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -24,32 +24,37 @@ impl Parse for Query { } #[derive(Debug, Clone, Copy)] -pub enum StringMethod{ +pub enum StringMethod { Contains, StartsWith, - EndsWith + EndsWith, } impl StringMethod { - pub fn from_ident(ident: &syn::Ident) -> Option{ + pub fn from_ident(ident: &syn::Ident) -> Option { match ident.to_string().as_str() { "contains" => Some(Self::Contains), "starts_with" => Some(Self::StartsWith), "ends_with" => Some(Self::EndsWith), - _ => None + _ => None, } } pub fn as_ident(&self) -> syn::Ident { - match self{ - Self::Contains => {format_ident!("contains")} - Self::StartsWith => {format_ident!("starts_with")} - Self::EndsWith => {format_ident!("ends_with")} + match self { + Self::Contains => { + format_ident!("contains") + } + Self::StartsWith => { + format_ident!("starts_with") + } + Self::EndsWith => { + format_ident!("ends_with") + } } } } - pub(super) fn query_to_tokens(query: Query) -> TokenStream { let crate_name = cot_ident(); let model_name = query.model_name; @@ -97,13 +102,13 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream ) .to_compile_error(), }, - Expr::FunctionCall { function, args } => { + Expr::FunctionCall { function, args } => { let field_free_tokens = function.as_tokens(); if field_free_tokens.is_none() { if let Expr::MemberAccess { member_name, .. } = &*function { if let Some(method) = StringMethod::from_ident(member_name) { - let Expr::MemberAccess { parent, ..} = *function else { + let Expr::MemberAccess { parent, .. } = *function else { unreachable!("function call must have a parent"); }; return handle_string_method(model_name, *parent, args, method); @@ -121,9 +126,9 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream (only `.contains(..)`, `.starts_with(..)`, and `.ends_with(..)` \ are supported directly on database fields)", ) - .to_compile_error(), + .to_compile_error(), } - }, + } Expr::And(lhs, rhs) => { let lhs = expr_to_tokens(model_name, *lhs); let rhs = expr_to_tokens(model_name, *rhs); @@ -169,7 +174,6 @@ fn handle_binary_comparison( quote!(#crate_name::db::query::Expr::#bin_fn(#lhs, #rhs)) } - fn handle_string_method( model_name: &syn::Type, receiver: Expr, @@ -189,7 +193,7 @@ fn handle_string_method( span, format!("`{method_ident}` expects exactly one string argument"), ) - .to_compile_error(); + .to_compile_error(); } }; diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 6b0b8222b..d7fbb38cc 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -597,7 +597,7 @@ pub enum Expr { Div(Box, Box), Contains(Box, Box), StartsWith(Box, Box), - EndsWith(Box, Box) + EndsWith(Box, Box), } impl Expr { @@ -1040,7 +1040,7 @@ impl Expr { Self::Div(lhs, rhs) => lhs.as_sea_query_expr().div(rhs.as_sea_query_expr()), Self::Contains(lhs, rhs) => like_expr(lhs, rhs, LikeMode::Contains), Self::StartsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::StartsWith), - Self::EndsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::EndsWith) + Self::EndsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::EndsWith), } } } @@ -1356,7 +1356,7 @@ impl ExprOrd for FieldRef { } } -pub trait ExprLike{ +pub trait ExprLike { fn contains>(self, other: V) -> Expr; fn starts_with>(self, other: V) -> Expr; fn ends_with>(self, other: V) -> Expr; @@ -1375,7 +1375,7 @@ impl ExprLike for FieldRef { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum LikeMode{ +enum LikeMode { Contains, StartsWith, EndsWith, @@ -1386,7 +1386,7 @@ const LIKE_ESCAPE_CHAR: char = '\\'; fn escape_like_value(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for c in value.chars() { - if matches!(c, '%' | '_' | LIKE_ESCAPE_CHAR){ + if matches!(c, '%' | '_' | LIKE_ESCAPE_CHAR) { escaped.push(LIKE_ESCAPE_CHAR) } escaped.push(c); @@ -1398,7 +1398,7 @@ fn build_like_pattern(value: &str, mode: LikeMode) -> sea_query::LikeExpr { let pattern = match mode { LikeMode::Contains => format!("%{escaped}%"), LikeMode::StartsWith => format!("%{escaped}"), - LikeMode::EndsWith => format!("{escaped}%") + LikeMode::EndsWith => format!("{escaped}%"), }; sea_query::LikeExpr::new(pattern).escape(LIKE_ESCAPE_CHAR) } @@ -1413,7 +1413,7 @@ fn like_expr(lhs: &Expr, rhs: &Expr, mode: LikeMode) -> sea_query::SimpleExpr { let sea_value: sea_query::Value = value.clone().into(); match sea_value { sea_query::Value::String(Some(s)) => lhs_expr.like(build_like_pattern(&s, mode)), - _ => sea_query::Expr::val(1).eq(0) + _ => sea_query::Expr::val(1).eq(0), } } From 3c25675244ee524f6972b30bfef2b2bfa03e0c2e Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 3 Jul 2026 22:35:26 +0000 Subject: [PATCH 03/24] - add .raw_like escape hatch - improve code quality - Add docs - refactor/move expr into a module since db/query file has grown bigger now --- cot-macros/src/model.rs | 4 +- cot-macros/src/query.rs | 150 ++- cot-macros/tests/query.rs | 5 +- cot/src/db.rs | 37 +- cot/src/db/impl_mysql.rs | 30 + cot/src/db/impl_postgres.rs | 21 + cot/src/db/impl_sqlite.rs | 51 + cot/src/db/query.rs | 1332 ++------------------------ cot/src/db/query/expr.rs | 1699 +++++++++++++++++++++++++++++++++ cot/src/db/query/expr/like.rs | 153 +++ cot/tests/db.rs | 2 +- docs/databases/queries.md | 10 +- 12 files changed, 2148 insertions(+), 1346 deletions(-) create mode 100644 cot/src/db/query/expr.rs create mode 100644 cot/src/db/query/expr/like.rs diff --git a/cot-macros/src/model.rs b/cot-macros/src/model.rs index 91006c40c..e885cf993 100644 --- a/cot-macros/src/model.rs +++ b/cot-macros/src/model.rs @@ -153,8 +153,8 @@ impl ModelBuilder { self.fields_as_field_refs.push(quote!( #[doc = concat!("Field reference to [`", stringify!(#name), "::", stringify!(#column_name), "`].")] - pub const #name: #orm_ident::query::FieldRef<#ty> = - #orm_ident::query::FieldRef::<#ty>::new(#orm_ident::Identifier::new(#column_name)); + pub const #name: #orm_ident::query::expr::FieldRef<#ty> = + #orm_ident::query::expr::FieldRef::<#ty>::new(#orm_ident::Identifier::new(#column_name)); )); } diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index d54fde118..ccb3e95b0 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -24,34 +24,85 @@ impl Parse for Query { } #[derive(Debug, Clone, Copy)] -pub enum StringMethod { - Contains, - StartsWith, - EndsWith, +pub(crate) enum StringMethod { + Contains { case_sensitive: bool }, + StartsWith { case_sensitive: bool }, + EndsWith { case_sensitive: bool }, + Raw { case_sensitive: bool }, } impl StringMethod { - pub fn from_ident(ident: &syn::Ident) -> Option { + pub(crate) fn from_ident(ident: &syn::Ident) -> Option { match ident.to_string().as_str() { - "contains" => Some(Self::Contains), - "starts_with" => Some(Self::StartsWith), - "ends_with" => Some(Self::EndsWith), + "contains" => Some(Self::Contains { + case_sensitive: true, + }), + "icontains" => Some(Self::Contains { + case_sensitive: false, + }), + "starts_with" => Some(Self::StartsWith { + case_sensitive: true, + }), + "istarts_with" => Some(Self::StartsWith { + case_sensitive: false, + }), + "ends_with" => Some(Self::EndsWith { + case_sensitive: true, + }), + "iends_with" => Some(Self::EndsWith { + case_sensitive: false, + }), + "raw" => Some(Self::Raw { + case_sensitive: true, + }), + "iraw" => Some(Self::Raw { + case_sensitive: false, + }), _ => None, } } - pub fn as_ident(&self) -> syn::Ident { - match self { - Self::Contains => { - format_ident!("contains") - } - Self::StartsWith => { - format_ident!("starts_with") - } - Self::EndsWith => { - format_ident!("ends_with") - } - } + pub(crate) fn as_ident(self) -> syn::Ident { + let name = match self { + Self::Contains { + case_sensitive: true, + } => "contains", + Self::Contains { + case_sensitive: false, + } => "icontains", + Self::StartsWith { + case_sensitive: true, + } => "starts_with", + Self::StartsWith { + case_sensitive: false, + } => "istarts_with", + Self::EndsWith { + case_sensitive: true, + } => "ends_with", + Self::EndsWith { + case_sensitive: false, + } => "iends_with", + Self::Raw { + case_sensitive: true, + } => "raw", + Self::Raw { + case_sensitive: false, + } => "iraw", + }; + format_ident!("{name}") + } + + pub(crate) fn all_names() -> &'static [&'static str] { + &[ + "contains", + "icontains", + "starts_with", + "istarts_with", + "ends_with", + "iends_with", + "raw", + "iraw", + ] } } @@ -72,7 +123,7 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream quote!(<#model_name as #crate_name::db::Model>::Fields::#field_name.as_expr()) } Expr::Value(value) => { - quote!(#crate_name::db::query::Expr::value(#value)) + quote!(#crate_name::db::query::expr::Expr::value(#value)) } Expr::MemberAccess { parent, @@ -80,7 +131,7 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream .. } => match parent.as_tokens() { Some(tokens) => { - quote!(#crate_name::db::query::Expr::value(#tokens.#member_name)) + quote!(#crate_name::db::query::expr::Expr::value(#tokens.#member_name)) } None => syn::Error::new_spanned( parent.as_tokens_full(), @@ -94,7 +145,7 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream .. } => match parent.as_tokens() { Some(tokens) => { - quote!(#crate_name::db::query::Expr::value(#tokens::#path_segment)) + quote!(#crate_name::db::query::expr::Expr::value(#tokens::#path_segment)) } None => syn::Error::new_spanned( parent.as_tokens_full(), @@ -103,41 +154,38 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream .to_compile_error(), }, Expr::FunctionCall { function, args } => { - let field_free_tokens = function.as_tokens(); - - if field_free_tokens.is_none() { - if let Expr::MemberAccess { member_name, .. } = &*function { - if let Some(method) = StringMethod::from_ident(member_name) { - let Expr::MemberAccess { parent, .. } = *function else { - unreachable!("function call must have a parent"); - }; - return handle_string_method(model_name, *parent, args, method); - } - } + let non_field_tokens = function.as_tokens(); + + if non_field_tokens.is_none() + && let Expr::MemberAccess { member_name, .. } = &*function + && let Some(method) = StringMethod::from_ident(member_name) + { + let Expr::MemberAccess { parent, .. } = *function else { + unreachable!("function call must have a parent"); + }; + return handle_string_method(model_name, *parent, args, method); } - match field_free_tokens { - Some(tokens) => { - quote!(#crate_name::db::query::Expr::value(#tokens(#(#args),*))) - } - None => syn::Error::new_spanned( - function.as_tokens_full(), + if let Some(tokens) = non_field_tokens { + quote!(#crate_name::db::query::expr::Expr::value(#tokens(#(#args),*))) + } else { + let all_function_names = StringMethod::all_names().join(", "); + let msg = format!( "calling functions that reference database fields is unsupported \ - (only `.contains(..)`, `.starts_with(..)`, and `.ends_with(..)` \ - are supported directly on database fields)", - ) - .to_compile_error(), + (only {all_function_names} are supported directly on database fields)" + ); + syn::Error::new_spanned(function.as_tokens_full(), msg).to_compile_error() } } Expr::And(lhs, rhs) => { let lhs = expr_to_tokens(model_name, *lhs); let rhs = expr_to_tokens(model_name, *rhs); - quote!(#crate_name::db::query::Expr::and(#lhs, #rhs)) + quote!(#crate_name::db::query::expr::Expr::and(#lhs, #rhs)) } Expr::Or(lhs, rhs) => { let lhs = expr_to_tokens(model_name, *lhs); let rhs = expr_to_tokens(model_name, *rhs); - quote!(#crate_name::db::query::Expr::or(#lhs, #rhs)) + quote!(#crate_name::db::query::expr::Expr::or(#lhs, #rhs)) } Expr::Eq(lhs, rhs) => handle_binary_comparison(model_name, *lhs, *rhs, "eq", "ExprEq"), Expr::Ne(lhs, rhs) => handle_binary_comparison(model_name, *lhs, *rhs, "ne", "ExprEq"), @@ -166,12 +214,12 @@ fn handle_binary_comparison( if let Expr::FieldRef { ref field_name, .. } = lhs && let Some(rhs_tokens) = rhs.as_tokens() { - return quote!(#crate_name::db::query::#bin_trait::#bin_fn(<#model_name as #crate_name::db::Model>::Fields::#field_name, #rhs_tokens)); + return quote!(#crate_name::db::query::expr::#bin_trait::#bin_fn(<#model_name as #crate_name::db::Model>::Fields::#field_name, #rhs_tokens)); } let lhs = expr_to_tokens(model_name, lhs); let rhs = expr_to_tokens(model_name, rhs); - quote!(#crate_name::db::query::Expr::#bin_fn(#lhs, #rhs)) + quote!(#crate_name::db::query::expr::Expr::#bin_fn(#lhs, #rhs)) } fn handle_string_method( @@ -199,7 +247,7 @@ fn handle_string_method( if let Expr::FieldRef { ref field_name, .. } = receiver { return quote! { - #crate_name::db::query::ExprLike::#method_ident( + #crate_name::db::query::expr::ExprLike::#method_ident( <#model_name as #crate_name::db::Model>::Fields::#field_name, #arg ) @@ -208,9 +256,9 @@ fn handle_string_method( let receiver_tokens = expr_to_tokens(model_name, receiver); quote! { - #crate_name::db::query::Expr::#method_ident( + #crate_name::db::query::expr::Expr::#method_ident( #receiver_tokens, - #crate_name::db::query::Expr::value(#arg) + #crate_name::db::query::expr::Expr::value(#arg) ) } } diff --git a/cot-macros/tests/query.rs b/cot-macros/tests/query.rs index b9024057d..f88e65cb6 100644 --- a/cot-macros/tests/query.rs +++ b/cot-macros/tests/query.rs @@ -1,4 +1,5 @@ -use cot::db::query::{Expr, ExprAdd, ExprDiv, ExprEq, ExprMul, ExprOrd, ExprSub, Query}; +use cot::db::query::Query; +use cot::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprEq, ExprMul, ExprOrd, ExprSub}; use cot::db::{model, query}; #[model] @@ -189,7 +190,7 @@ fn test_query_mul_fields() { ); assert_eq!( - ::objects().filter(::cot::db::query::ExprMul::mul( + ::objects().filter(::cot::db::query::expr::ExprMul::mul( ::Fields::quantity, 5i64 )), diff --git a/cot/src/db.rs b/cot/src/db.rs index 9910032e0..11473d951 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -424,6 +424,7 @@ use derive_more::{Debug, Deref, Display}; #[cfg(test)] use mockall::automock; use query::Query; +use query::expr::like::{CaseSensitivity, LikeDialect}; pub use relations::{ForeignKey, ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy}; use sea_query::{ ColumnRef, ExprTrait, Iden, IntoColumnRef, OnConflict, ReturningClause, SchemaStatementBuilder, @@ -442,6 +443,7 @@ use crate::db::impl_postgres::{DatabasePostgres, PostgresRow, PostgresValueRef}; #[cfg(feature = "sqlite")] use crate::db::impl_sqlite::{DatabaseSqlite, SqliteRow, SqliteValueRef}; use crate::db::migrations::ColumnTypeMapper; +use crate::db::query::QueryBuildingError; const ERROR_PREFIX: &str = "database error:"; /// An error that can occur when interacting with the database. @@ -453,7 +455,7 @@ pub enum DatabaseError { DatabaseEngineError(#[from] sqlx::Error), /// Error when building query. #[error("{ERROR_PREFIX} error when building query: {0}")] - QueryBuildingError(#[from] sea_query::error::Error), + QueryBuildingError(#[from] QueryBuildingError), /// Type mismatch in database value. #[error( "{ERROR_PREFIX} type mismatch in database value: expected `{expected}`, found `{found}`. \ @@ -1366,7 +1368,8 @@ impl Database { .into_iter() .map(SimpleExpr::Value) .collect::>(), - )? + ) + .map_err(QueryBuildingError::SeaQuery)? .or_default_values() .to_owned(); if update && !value_identifiers.is_empty() { @@ -1630,7 +1633,9 @@ impl Database { .collect(); debug_assert!(!db_values.is_empty(), "expected at least 1 value field"); - insert_statement.values(db_values)?; + insert_statement + .values(db_values) + .map_err(QueryBuildingError::SeaQuery)?; } if update { @@ -1727,7 +1732,7 @@ impl Database { let columns_to_get: Vec<_> = T::COLUMNS.iter().map(|column| column.name).collect(); let mut select = sea_query::Query::select(); select.columns(columns_to_get).from(T::TABLE_NAME); - query.add_filter_to_statement(&mut select); + query.add_filter_to_statement(&mut select, self)?; query.add_limit_to_statement(&mut select); query.add_offset_to_statement(&mut select); @@ -1753,7 +1758,7 @@ impl Database { let columns_to_get: Vec<_> = T::COLUMNS.iter().map(|column| column.name).collect(); let mut select = sea_query::Query::select(); select.columns(columns_to_get).from(T::TABLE_NAME); - query.add_filter_to_statement(&mut select); + query.add_filter_to_statement(&mut select, self)?; select.limit(1); let row = self.fetch_option(&select).await?; @@ -1779,7 +1784,7 @@ impl Database { pub async fn exists(&self, query: &Query) -> Result { let mut select = sea_query::Query::select(); select.expr(sea_query::Expr::value(1)).from(T::TABLE_NAME); - query.add_filter_to_statement(&mut select); + query.add_filter_to_statement(&mut select, self)?; select.limit(1); let rows = self.fetch_option(&select).await?; @@ -1801,7 +1806,7 @@ impl Database { pub async fn delete(&self, query: &Query) -> Result { let mut delete = sea_query::Query::delete(); delete.from_table(T::TABLE_NAME); - query.add_filter_to_statement(&mut delete); + query.add_filter_to_statement(&mut delete, self)?; self.execute_statement(&delete).await } @@ -1983,6 +1988,24 @@ impl ColumnTypeMapper for Database { } } +impl LikeDialect for Database { + fn like_expr( + &self, + lhs: SimpleExpr, + glob_pattern: &str, + case_sensitivity: CaseSensitivity, + ) -> std::result::Result { + match &*self.inner { + #[cfg(feature = "sqlite")] + DatabaseImpl::Sqlite(inner) => inner.like_expr(lhs, glob_pattern, case_sensitivity), + #[cfg(feature = "postgres")] + DatabaseImpl::Postgres(inner) => inner.like_expr(lhs, glob_pattern, case_sensitivity), + #[cfg(feature = "mysql")] + DatabaseImpl::MySql(inner) => inner.like_expr(lhs, glob_pattern, case_sensitivity), + } + } +} + /// A trait that provides a backend for the database. /// /// This trait is used to provide a backend for the database. diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index aff017772..34d0ff1e3 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -1,6 +1,10 @@ //! Database interface implementation – MySQL backend. +use sea_query::{ExprTrait, SimpleExpr}; + use crate::db::ColumnType; +use crate::db::query::QueryBuildingError; +use crate::db::query::expr::like::{CaseSensitivity, LikeDialect, to_sql_like}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabaseMySql: sqlx::mysql::MySql, sqlx::mysql::MySqlPool, MySqlRow, MySqlValueRef, sea_query::MysqlQueryBuilder); @@ -35,3 +39,29 @@ impl DatabaseMySql { sea_query::ColumnType::from(column_type) } } + +impl LikeDialect for DatabaseMySql { + fn like_expr( + &self, + lhs: SimpleExpr, + glob_pattern: &str, + case_sensitivity: CaseSensitivity, + ) -> Result { + let glob = to_sql_like(glob_pattern); + + match case_sensitivity { + CaseSensitivity::Sensitive => { + let expr = lhs + .binary( + sea_query::BinOper::Custom("COLLATE"), + sea_query::Expr::cust("utf8mb4_bin"), + ) + .like(glob); + Ok(expr) + } + CaseSensitivity::Insensitive => { + Ok(sea_query::Func::lower(lhs).like(glob.to_lowercase())) + } + } + } +} diff --git a/cot/src/db/impl_postgres.rs b/cot/src/db/impl_postgres.rs index b54deadbf..fee6c6d9d 100644 --- a/cot/src/db/impl_postgres.rs +++ b/cot/src/db/impl_postgres.rs @@ -1,5 +1,10 @@ //! Database interface implementation – PostgreSQL backend. +use cot::db::query::QueryBuildingError; +use sea_query::extension::postgres::PgExpr; +use sea_query::{ExprTrait, SimpleExpr}; + +use crate::db::query::expr::like::{CaseSensitivity, LikeDialect, to_sql_like}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabasePostgres: sqlx::postgres::Postgres, sqlx::postgres::PgPool, PostgresRow, PostgresValueRef, sea_query::PostgresQueryBuilder); @@ -42,3 +47,19 @@ impl DatabasePostgres { sea_query::ColumnType::from(column_type) } } + +impl LikeDialect for DatabasePostgres { + fn like_expr( + &self, + lhs: SimpleExpr, + glob_pattern: &str, + case_sensitivity: CaseSensitivity, + ) -> Result { + let glob = to_sql_like(glob_pattern); + + match case_sensitivity { + CaseSensitivity::Sensitive => Ok(lhs.like(glob)), + CaseSensitivity::Insensitive => Ok(lhs.ilike(glob)), + } + } +} diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 576ab9db0..64b74945b 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -1,7 +1,11 @@ //! Database interface implementation – SQLite backend. +use sea_query::extension::sqlite::SqliteExpr; +use sea_query::{ExprTrait, SimpleExpr}; use sea_query_sqlx::SqlxValues; +use crate::db::query::QueryBuildingError; +use crate::db::query::expr::like::{CaseSensitivity, LIKE_ESCAPE_CHAR, LikeDialect}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabaseSqlite: sqlx::sqlite::Sqlite, sqlx::sqlite::SqlitePool, SqliteRow, SqliteValueRef, sea_query::SqliteQueryBuilder); @@ -35,3 +39,50 @@ impl DatabaseSqlite { sea_query::ColumnType::from(column_type) } } + +impl DatabaseSqlite { + fn convert_to_sqlite_glob(glob: &str) -> String { + let mut escaped = String::with_capacity(glob.len()); + let mut chars = glob.chars(); + while let Some(ch) = chars.next() { + match ch { + LIKE_ESCAPE_CHAR => { + if let Some(ch) = chars.next() { + Self::push_glob_literal(&mut escaped, ch); + } + } + other => Self::push_glob_literal(&mut escaped, other), + } + } + escaped + } + + fn push_glob_literal(out: &mut String, ch: char) { + if matches!(ch, '*' | '?' | '[') { + out.push('['); + out.push(ch); + out.push(']'); + } else { + out.push(ch); + } + } +} + +impl LikeDialect for DatabaseSqlite { + fn like_expr( + &self, + lhs: SimpleExpr, + glob_pattern: &str, + case_sensitivity: CaseSensitivity, + ) -> Result { + match case_sensitivity { + CaseSensitivity::Sensitive => { + let glob = Self::convert_to_sqlite_glob(glob_pattern); + Ok(lhs.glob(glob)) + } + CaseSensitivity::Insensitive => { + Ok(sea_query::Func::lower(lhs).like(glob_pattern.to_lowercase())) + } + } + } +} diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index d7fbb38cc..68aadb2f7 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -1,15 +1,32 @@ //! Database query builder. +pub mod expr; + use std::marker::PhantomData; use derive_more::with_trait::Debug; -use sea_query::{ExprTrait, IntoColumnRef}; +use sea_query::ExprTrait; +use thiserror::Error; use crate::db; +use crate::db::query::expr::SqlDialect; +pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub}; use crate::db::{ - Auto, Database, DatabaseBackend, DbFieldValue, DbValue, ForeignKey, FromDbValue, Identifier, - Model, StatementResult, ToDbFieldValue, + Auto, Database, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue, }; +const ERROR_PREFIX: &str = "expression error:"; + +/// An error that can occur when building a query. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum QueryBuildingError { + /// Error when building an expression. + #[error("{ERROR_PREFIX} unsupported expression: {0}")] + UnsupportedExpr(String), + /// Error when building a query. + #[error(transparent)] + SeaQuery(#[from] sea_query::error::Error), +} /// A query that can be executed on a database. Can be used to filter, update, /// or delete rows. @@ -201,7 +218,7 @@ impl Query { select .from(T::TABLE_NAME) .expr(sea_query::Expr::col(sea_query::Asterisk).count()); - self.add_filter_to_statement(&mut select); + self.add_filter_to_statement(&mut select, db)?; let row = db.fetch_option(&select).await?; let count = match row { #[expect(clippy::cast_sign_loss)] @@ -232,10 +249,12 @@ impl Query { pub(super) fn add_filter_to_statement( &self, statement: &mut S, - ) { + db: &dyn SqlDialect, + ) -> Result<(), QueryBuildingError> { if let Some(filter) = &self.filter { - statement.and_where(filter.as_sea_query_expr()); + statement.and_where(filter.as_sea_query_expr(db)?); } + Ok(()) } pub(super) fn add_limit_to_statement(&self, statement: &mut sea_query::SelectStatement) { @@ -251,1251 +270,56 @@ impl Query { } } -/// An expression that can be used to filter, update, or delete rows. +/// A trait for database types that can be converted to the field type. /// -/// This is used to create complex queries with multiple conditions. Typically, -/// it is only internally used by the [`cot::db::query!`] macro to create a -/// [`Query`]. +/// This trait is mostly a helper trait to make comparisons like `$id == 5` +/// where `id` is of type [`Auto`] or [`ForeignKey`] easier to write and more +/// readable. /// /// # Example /// /// ``` -/// use cot::db::{model, query}; -/// use cot::db::query::{Expr, Query}; +/// use cot::db::query::Query; +/// use cot::db::query::expr::{Expr, ExprEq}; +/// use cot::db::{Auto, model, query}; /// /// #[model] /// struct MyModel { /// #[model(primary_key)] -/// id: i32, +/// id: Auto, /// }; /// -/// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); -/// -/// assert_eq!( -/// >::new().filter(expr), -/// query!(MyModel, $id == 5) -/// ); +/// // uses the `IntoField` trait to convert the `5` to `Auto` +/// let expr = ::Fields::id.eq(5); /// ``` -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum Expr { - /// An expression containing a reference to a column. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == 5) - /// ); - /// ``` - Field(Identifier), - /// An expression containing a literal value. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id != 5) - /// ); - /// ``` - Value(DbValue), - /// An `AND` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::and( - /// Expr::gt(Expr::field("id"), Expr::value(10)), - /// Expr::lt(Expr::field("id"), Expr::value(20)) - /// ); - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 10 && $id < 20) - /// ); - /// ``` - And(Box, Box), - /// An `OR` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::or( - /// Expr::gt(Expr::field("id"), Expr::value(10)), - /// Expr::lt(Expr::field("id"), Expr::value(20)) - /// ); - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 10 || $id < 20) - /// ); - /// ``` - Or(Box, Box), - /// An `=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == 5) - /// ); - /// ``` - Eq(Box, Box), - /// A `!=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id != 5) - /// ); - /// ``` - Ne(Box, Box), - /// A `<` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::lt(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id < 5) - /// ); - /// ``` - Lt(Box, Box), - /// A `<=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::lte(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id <= 5) - /// ); - /// ``` - Lte(Box, Box), - /// A `>` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::gt(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 5) - /// ); - /// ``` - Gt(Box, Box), - /// A `>=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::gte(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id >= 5) - /// ); - /// ``` - Gte(Box, Box), - /// A `+` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::add(Expr::field("id_2"), Expr::value(5))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 + 5) - /// ); - /// ``` - Add(Box, Box), - /// A `-` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::sub(Expr::field("id_2"), Expr::value(5))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 - 5) - /// ); - /// ``` - Sub(Box, Box), - /// A `*` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::mul(Expr::field("id_2"), Expr::value(2))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 * 2) - /// ); - /// ``` - Mul(Box, Box), - /// A `/` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::div(Expr::field("id_2"), Expr::value(2))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 / 2) - /// ); - /// ``` - Div(Box, Box), - Contains(Box, Box), - StartsWith(Box, Box), - EndsWith(Box, Box), +pub trait IntoField { + /// Converts the type to the field type. + fn into_field(self) -> T; } -impl Expr { - /// Create a new field expression. This represents a reference to a column - /// in the database. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == 5) - /// ); - /// ``` - #[must_use] - pub fn field>(identifier: T) -> Self { - Self::Field(identifier.into()) +impl IntoField for T { + fn into_field(self) -> T { + self } +} - /// Create a new value expression. This represents a literal value that gets - /// passed into the SQL query. - /// - /// # Panics - /// - /// If the value provided is a [`DbFieldValue::Auto`]. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id != 5) - /// ); - /// ``` - #[must_use] - #[expect(clippy::needless_pass_by_value)] - pub fn value(value: T) -> Self { - match value.to_db_field_value() { - DbFieldValue::Value(value) => Self::Value(value), - DbFieldValue::Auto => panic!("Cannot create query with a non-value field"), - } +impl IntoField> for T { + fn into_field(self) -> Auto { + Auto::fixed(self) } +} - /// Create a new `AND` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::and( - /// Expr::gt(Expr::field("id"), Expr::value(10)), - /// Expr::lt(Expr::field("id"), Expr::value(20)) - /// ); - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 10 && $id < 20) - /// ); - /// ``` - #[must_use] - pub fn and(lhs: Self, rhs: Self) -> Self { - Self::And(Box::new(lhs), Box::new(rhs)) +impl IntoField for &str { + fn into_field(self) -> String { + self.to_string() } +} - /// Create a new `OR` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::or( - /// Expr::gt(Expr::field("id"), Expr::value(10)), - /// Expr::lt(Expr::field("id"), Expr::value(20)) - /// ); - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 10 || $id < 20) - /// ); - /// ``` - #[must_use] - pub fn or(lhs: Self, rhs: Self) -> Self { - Self::Or(Box::new(lhs), Box::new(rhs)) +impl IntoField> for T { + fn into_field(self) -> ForeignKey { + ForeignKey::from(self) } - - /// Create a new `=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == 5) - /// ); - /// ``` - #[must_use] - pub fn eq(lhs: Self, rhs: Self) -> Self { - Self::Eq(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `!=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id != 5) - /// ); - /// ``` - #[must_use] - pub fn ne(lhs: Self, rhs: Self) -> Self { - Self::Ne(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `<` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::lt(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id < 5) - /// ); - /// ``` - #[must_use] - pub fn lt(lhs: Self, rhs: Self) -> Self { - Self::Lt(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `<=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::lte(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id <= 5) - /// ); - /// ``` - #[must_use] - pub fn lte(lhs: Self, rhs: Self) -> Self { - Self::Lte(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `>` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::gt(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 5) - /// ); - /// ``` - #[must_use] - pub fn gt(lhs: Self, rhs: Self) -> Self { - Self::Gt(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `>=` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = Expr::gte(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id >= 5) - /// ); - /// ``` - #[must_use] - pub fn gte(lhs: Self, rhs: Self) -> Self { - Self::Gte(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `+` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::add(Expr::field("id_2"), Expr::value(5))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 + 5) - /// ); - /// ``` - #[expect(clippy::should_implement_trait)] - #[must_use] - pub fn add(lhs: Self, rhs: Self) -> Self { - Self::Add(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `-` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::sub(Expr::field("id_2"), Expr::value(5))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 - 5) - /// ); - /// ``` - #[expect(clippy::should_implement_trait)] - #[must_use] - pub fn sub(lhs: Self, rhs: Self) -> Self { - Self::Sub(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `*` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::mul(Expr::field("id_2"), Expr::value(2))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 * 2) - /// ); - /// ``` - #[expect(clippy::should_implement_trait)] - #[must_use] - pub fn mul(lhs: Self, rhs: Self) -> Self { - Self::Mul(Box::new(lhs), Box::new(rhs)) - } - - /// Create a new `/` expression. - /// - /// # Example - /// - /// ``` - /// use cot::db::{model, query}; - /// use cot::db::query::{Expr, Query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// id_2: i32, - /// }; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::div(Expr::field("id_2"), Expr::value(2))); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == $id_2 / 2) - /// ); - /// ``` - #[expect(clippy::should_implement_trait)] - #[must_use] - pub fn div(lhs: Self, rhs: Self) -> Self { - Self::Div(Box::new(lhs), Box::new(rhs)) - } - - #[must_use] - pub fn contains(lhs: Self, rhs: Self) -> Self { - Self::Contains(Box::new(lhs), Box::new(rhs)) - } - - #[must_use] - pub fn starts_with(lhs: Self, rhs: Self) -> Self { - Self::StartsWith(Box::new(lhs), Box::new(rhs)) - } - - #[must_use] - pub fn ends_with(lhs: Self, rhs: Self) -> Self { - Self::EndsWith(Box::new(lhs), Box::new(rhs)) - } - - /// Returns the expression as a [`sea_query::SimpleExpr`]. - /// - /// # Example - /// - /// ``` - /// use cot::db::Identifier; - /// use cot::db::query::Expr; - /// use sea_query::{ExprTrait, IntoColumnRef}; - /// - /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); - /// - /// assert_eq!( - /// expr.as_sea_query_expr(), - /// ExprTrait::eq( - /// sea_query::SimpleExpr::Column(Identifier::new("id").into_column_ref()), - /// sea_query::SimpleExpr::Value(sea_query::Value::Int(Some(5))) - /// ) - /// ); - /// ``` - #[must_use] - pub fn as_sea_query_expr(&self) -> sea_query::SimpleExpr { - match self { - Self::Field(identifier) => (*identifier).into_column_ref().into(), - Self::Value(value) => (*value).clone().into(), - Self::And(lhs, rhs) => lhs.as_sea_query_expr().and(rhs.as_sea_query_expr()), - Self::Or(lhs, rhs) => lhs.as_sea_query_expr().or(rhs.as_sea_query_expr()), - Self::Eq(lhs, rhs) => lhs.as_sea_query_expr().eq(rhs.as_sea_query_expr()), - Self::Ne(lhs, rhs) => lhs.as_sea_query_expr().ne(rhs.as_sea_query_expr()), - Self::Lt(lhs, rhs) => lhs.as_sea_query_expr().lt(rhs.as_sea_query_expr()), - Self::Lte(lhs, rhs) => lhs.as_sea_query_expr().lte(rhs.as_sea_query_expr()), - Self::Gt(lhs, rhs) => lhs.as_sea_query_expr().gt(rhs.as_sea_query_expr()), - Self::Gte(lhs, rhs) => lhs.as_sea_query_expr().gte(rhs.as_sea_query_expr()), - Self::Add(lhs, rhs) => lhs.as_sea_query_expr().add(rhs.as_sea_query_expr()), - Self::Sub(lhs, rhs) => lhs.as_sea_query_expr().sub(rhs.as_sea_query_expr()), - Self::Mul(lhs, rhs) => lhs.as_sea_query_expr().mul(rhs.as_sea_query_expr()), - Self::Div(lhs, rhs) => lhs.as_sea_query_expr().div(rhs.as_sea_query_expr()), - Self::Contains(lhs, rhs) => like_expr(lhs, rhs, LikeMode::Contains), - Self::StartsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::StartsWith), - Self::EndsWith(lhs, rhs) => like_expr(lhs, rhs, LikeMode::EndsWith), - } - } -} - -/// A reference to a field in a database table. -/// -/// This is used to create expressions that reference a specific column in a -/// table with a specific type. This allows for type-safe creation of queries -/// with some common operators like `=`, `!=`, `+`, `-`, `*`, and `/`. -#[derive(Debug)] -pub struct FieldRef { - identifier: Identifier, - phantom_data: PhantomData, -} - -impl FieldRef { - /// Create a new field reference. - #[must_use] - pub const fn new(identifier: Identifier) -> Self { - Self { - identifier, - phantom_data: PhantomData, - } - } -} - -impl FieldRef { - /// Returns the field reference as an [`Expr`]. - #[must_use] - pub fn as_expr(&self) -> Expr { - Expr::Field(self.identifier) - } -} - -/// A trait for types that can be compared in database expressions. -pub trait ExprEq { - /// Creates an expression that checks if the field is equal to the given - /// value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprEq, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.eq(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id == 5) - /// ); - /// ``` - fn eq>(self, other: V) -> Expr; - - /// Creates an expression that checks if the field is not equal to the given - /// value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprEq, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.ne(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id != 5) - /// ); - /// ``` - fn ne>(self, other: V) -> Expr; -} - -impl ExprEq for FieldRef { - fn eq>(self, other: V) -> Expr { - Expr::eq(self.as_expr(), Expr::value(other.into_field())) - } - - fn ne>(self, other: V) -> Expr { - Expr::ne(self.as_expr(), Expr::value(other.into_field())) - } -} - -/// A trait for database types that can be added to each other. -pub trait ExprAdd { - /// Creates an expression that adds the field to the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprAdd, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.add(5); - /// - /// assert_eq!( - /// >::new().filter(Expr::eq(Expr::field("id"), expr)), - /// query!(MyModel, $id == $id + 5) - /// ); - /// ``` - fn add>(self, other: V) -> Expr; -} - -/// A trait for database types that can be subtracted from each other. -pub trait ExprSub { - /// Creates an expression that subtracts the field from the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprSub, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.sub(5); - /// - /// assert_eq!( - /// >::new().filter(Expr::eq(Expr::field("id"), expr)), - /// query!(MyModel, $id == $id - 5) - /// ); - /// ``` - fn sub>(self, other: V) -> Expr; -} - -/// A trait for database types that can be multiplied by each other. -pub trait ExprMul { - /// Creates an expression that multiplies the field by the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprMul, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.mul(2); - /// - /// assert_eq!( - /// >::new().filter(Expr::eq(Expr::field("id"), expr)), - /// query!(MyModel, $id == $id * 2) - /// ); - /// ``` - fn mul>(self, other: V) -> Expr; -} - -/// A trait for database types that can be divided by each other. -pub trait ExprDiv { - /// Creates an expression that divides the field by the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprDiv, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.div(2); - /// - /// assert_eq!( - /// >::new().filter(Expr::eq(Expr::field("id"), expr)), - /// query!(MyModel, $id == $id / 2) - /// ); - /// ``` - fn div>(self, other: V) -> Expr; -} - -/// A trait for database types that can be ordered. -pub trait ExprOrd { - /// Creates an expression that checks if the field is less than the given - /// value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprOrd, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.lt(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id < 5) - /// ); - /// ``` - fn lt>(self, other: V) -> Expr; - /// Creates an expression that checks if the field is less than or equal to - /// the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprOrd, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.lte(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id <= 5) - /// ); - /// ``` - fn lte>(self, other: V) -> Expr; - - /// Creates an expression that checks if the field is greater than the given - /// value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprOrd, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.gt(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id > 5) - /// ); - /// ``` - fn gt>(self, other: V) -> Expr; - - /// Creates an expression that checks if the field is greater than or equal - /// to the given value. - /// - /// # Examples - /// - /// ``` - /// use cot::db::query::{Expr, ExprOrd, Query}; - /// use cot::db::{model, query}; - /// - /// #[model] - /// struct MyModel { - /// #[model(primary_key)] - /// id: i32, - /// }; - /// - /// let expr = ::Fields::id.gte(5); - /// - /// assert_eq!( - /// >::new().filter(expr), - /// query!(MyModel, $id >= 5) - /// ); - /// ``` - fn gte>(self, other: V) -> Expr; -} - -impl ExprOrd for FieldRef { - fn lt>(self, other: V) -> Expr { - Expr::lt(self.as_expr(), Expr::value(other.into_field())) - } - - fn lte>(self, other: V) -> Expr { - Expr::lte(self.as_expr(), Expr::value(other.into_field())) - } - - fn gt>(self, other: V) -> Expr { - Expr::gt(self.as_expr(), Expr::value(other.into_field())) - } - - fn gte>(self, other: V) -> Expr { - Expr::gte(self.as_expr(), Expr::value(other.into_field())) - } -} - -pub trait ExprLike { - fn contains>(self, other: V) -> Expr; - fn starts_with>(self, other: V) -> Expr; - fn ends_with>(self, other: V) -> Expr; -} - -impl ExprLike for FieldRef { - fn contains>(self, other: V) -> Expr { - Expr::contains(self.as_expr(), Expr::value(other.into_field())) - } - fn starts_with>(self, other: V) -> Expr { - Expr::starts_with(self.as_expr(), Expr::value(other.into_field())) - } - fn ends_with>(self, other: V) -> Expr { - Expr::ends_with(self.as_expr(), Expr::value(other.into_field())) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum LikeMode { - Contains, - StartsWith, - EndsWith, -} - -const LIKE_ESCAPE_CHAR: char = '\\'; - -fn escape_like_value(value: &str) -> String { - let mut escaped = String::with_capacity(value.len()); - for c in value.chars() { - if matches!(c, '%' | '_' | LIKE_ESCAPE_CHAR) { - escaped.push(LIKE_ESCAPE_CHAR) - } - escaped.push(c); - } - escaped -} -fn build_like_pattern(value: &str, mode: LikeMode) -> sea_query::LikeExpr { - let escaped = escape_like_value(value); - let pattern = match mode { - LikeMode::Contains => format!("%{escaped}%"), - LikeMode::StartsWith => format!("%{escaped}"), - LikeMode::EndsWith => format!("{escaped}%"), - }; - sea_query::LikeExpr::new(pattern).escape(LIKE_ESCAPE_CHAR) -} - -fn like_expr(lhs: &Expr, rhs: &Expr, mode: LikeMode) -> sea_query::SimpleExpr { - let lhs_expr = lhs.as_sea_query_expr(); - - let Expr::Value(value) = rhs else { - return sea_query::Expr::val(1).eq(0); - }; - - let sea_value: sea_query::Value = value.clone().into(); - match sea_value { - sea_query::Value::String(Some(s)) => lhs_expr.like(build_like_pattern(&s, mode)), - _ => sea_query::Expr::val(1).eq(0), - } -} - -macro_rules! impl_expr { - ($ty:ty, $trait:ident, $method:ident) => { - impl $trait<$ty> for FieldRef<$ty> { - fn $method>(self, other: V) -> Expr { - Expr::$method(self.as_expr(), Expr::value(other.into())) - } - } - }; -} - -macro_rules! impl_num_expr { - ($ty:ty) => { - impl_expr!($ty, ExprAdd, add); - impl_expr!($ty, ExprSub, sub); - impl_expr!($ty, ExprMul, mul); - impl_expr!($ty, ExprDiv, div); - }; -} - -impl_num_expr!(i8); -impl_num_expr!(i16); -impl_num_expr!(i32); -impl_num_expr!(i64); -impl_num_expr!(u8); -impl_num_expr!(u16); -impl_num_expr!(u32); -impl_num_expr!(u64); -impl_num_expr!(f32); -impl_num_expr!(f64); - -/// A trait for database types that can be converted to the field type. -/// -/// This trait is mostly a helper trait to make comparisons like `$id == 5` -/// where `id` is of type [`Auto`] or [`ForeignKey`] easier to write and more -/// readable. -/// -/// # Example -/// -/// ``` -/// use cot::db::query::{Expr, ExprEq, Query}; -/// use cot::db::{Auto, model, query}; -/// -/// #[model] -/// struct MyModel { -/// #[model(primary_key)] -/// id: Auto, -/// }; -/// -/// // uses the `IntoField` trait to convert the `5` to `Auto` -/// let expr = ::Fields::id.eq(5); -/// ``` -pub trait IntoField { - /// Converts the type to the field type. - fn into_field(self) -> T; -} - -impl IntoField for T { - fn into_field(self) -> T { - self - } -} - -impl IntoField> for T { - fn into_field(self) -> Auto { - Auto::fixed(self) - } -} - -impl IntoField for &str { - fn into_field(self) -> String { - self.to_string() - } -} - -impl IntoField> for T { - fn into_field(self) -> ForeignKey { - ForeignKey::from(self) - } -} +} impl IntoField> for &T { fn into_field(self) -> ForeignKey { @@ -1605,52 +429,4 @@ mod tests { assert!(result.is_ok()); } - - #[test] - fn expr_field() { - let expr = Expr::field("name"); - if let Expr::Field(identifier) = expr { - assert_eq!(identifier.to_string(), "name"); - } else { - panic!("Expected Expr::Field"); - } - } - - #[test] - fn expr_value() { - let expr = Expr::value(30); - if let Expr::Value(value) = expr { - assert_eq!(value.to_string(), "30"); - } else { - panic!("Expected Expr::Value"); - } - } - - macro_rules! test_expr_constructor { - ($test_name:ident, $match:ident, $constructor:ident) => { - #[test] - fn $test_name() { - let expr = Expr::$constructor(Expr::field("name"), Expr::value("John")); - if let Expr::$match(lhs, rhs) = expr { - assert!(matches!(*lhs, Expr::Field(_))); - assert!(matches!(*rhs, Expr::Value(_))); - } else { - panic!(concat!("Expected Expr::", stringify!($match))); - } - } - }; - } - - test_expr_constructor!(expr_and, And, and); - test_expr_constructor!(expr_or, Or, or); - test_expr_constructor!(expr_eq, Eq, eq); - test_expr_constructor!(expr_ne, Ne, ne); - test_expr_constructor!(expr_lt, Lt, lt); - test_expr_constructor!(expr_lte, Lte, lte); - test_expr_constructor!(expr_gt, Gt, gt); - test_expr_constructor!(expr_gte, Gte, gte); - test_expr_constructor!(expr_add, Add, add); - test_expr_constructor!(expr_sub, Sub, sub); - test_expr_constructor!(expr_mul, Mul, mul); - test_expr_constructor!(expr_div, Div, div); } diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs new file mode 100644 index 000000000..ede8aca59 --- /dev/null +++ b/cot/src/db/query/expr.rs @@ -0,0 +1,1699 @@ +//! Database expressions. +pub mod like; + +use std::marker::PhantomData; + +use cot::db::query::{IntoField, QueryBuildingError}; +use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; +pub use like::ExprLike; +use like::{CaseSensitivity, LikeDialect, LikeMode}; +use sea_query::{ExprTrait, IntoColumnRef, SimpleExpr}; + +/// An expression that can be used to filter, update, or delete rows. +/// +/// This is used to create complex queries with multiple conditions. Typically, +/// it is only internally used by the [`cot::db::query!`] macro to create a +/// [`Query`]. +/// +/// # Example +/// +/// ``` +/// use cot::db::{model, query}; +/// use cot::db::query::Query; +/// use cot::db::query::expr::Expr; +/// +/// #[model] +/// struct MyModel { +/// #[model(primary_key)] +/// id: i32, +/// }; +/// +/// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); +/// +/// assert_eq!( +/// >::new().filter(expr), +/// query!(MyModel, $id == 5) +/// ); +/// ``` +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Expr { + /// An expression containing a reference to a column. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == 5) + /// ); + /// ``` + Field(Identifier), + /// An expression containing a literal value. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id != 5) + /// ); + /// ``` + Value(DbValue), + /// An `AND` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::and( + /// Expr::gt(Expr::field("id"), Expr::value(10)), + /// Expr::lt(Expr::field("id"), Expr::value(20)) + /// ); + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 10 && $id < 20) + /// ); + /// ``` + And(Box, Box), + /// An `OR` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::or( + /// Expr::gt(Expr::field("id"), Expr::value(10)), + /// Expr::lt(Expr::field("id"), Expr::value(20)) + /// ); + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 10 || $id < 20) + /// ); + /// ``` + Or(Box, Box), + /// An `=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == 5) + /// ); + /// ``` + Eq(Box, Box), + /// A `!=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id != 5) + /// ); + /// ``` + Ne(Box, Box), + /// A `<` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::lt(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id < 5) + /// ); + /// ``` + Lt(Box, Box), + /// A `<=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::lte(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id <= 5) + /// ); + /// ``` + Lte(Box, Box), + /// A `>` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::gt(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 5) + /// ); + /// ``` + Gt(Box, Box), + /// A `>=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::gte(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id >= 5) + /// ); + /// ``` + Gte(Box, Box), + /// A `+` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::add(Expr::field("id_2"), Expr::value(5))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 + 5) + /// ); + /// ``` + Add(Box, Box), + /// A `-` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::sub(Expr::field("id_2"), Expr::value(5))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 - 5) + /// ); + /// ``` + Sub(Box, Box), + /// A `*` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::mul(Expr::field("id_2"), Expr::value(2))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 * 2) + /// ); + /// ``` + Mul(Box, Box), + /// A `/` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::div(Expr::field("id_2"), Expr::value(2))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 / 2) + /// ); + /// ``` + Div(Box, Box), + /// A case-sensitive substring match, checking whether the left-hand + /// expression contains the right-hand expression as a literal + /// substring. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// }; + /// + /// let expr = Expr::contains(Expr::field("name"), Expr::value("test")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.contains("test")) + /// ); + /// ``` + Contains(Box, Box, CaseSensitivity), + /// A prefix match, checking whether the left-hand expression starts + /// with the right-hand expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// }; + /// + /// let expr = Expr::starts_with(Expr::field("name"), Expr::value("Mr.")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.starts_with("Mr.")) + /// ); + /// ``` + StartsWith(Box, Box, CaseSensitivity), + /// A suffix match, checking whether the left-hand expression ends + /// with the right-hand expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// email: String, + /// }; + /// + /// let expr = Expr::ends_with(Expr::field("email"), Expr::value("@example.com")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $email.ends_with("@example.com")) + /// ); + /// ``` + EndsWith(Box, Box, CaseSensitivity), + /// A match against a raw pattern expressed in Cot's glob syntax. + /// See [`Expr::raw_like`] for the full syntax reference. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// code: String, + /// }; + /// + /// let expr = Expr::raw_like(Expr::field("code"), Expr::value("f??o")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $code.raw_like("f??o")) + /// ); + /// ``` + RawLike(Box, Box, CaseSensitivity), +} + +impl Expr { + /// Create a new field expression. This represents a reference to a column + /// in the database. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == 5) + /// ); + /// ``` + #[must_use] + pub fn field>(identifier: T) -> Self { + Self::Field(identifier.into()) + } + + /// Create a new value expression. This represents a literal value that gets + /// passed into the SQL query. + /// + /// # Panics + /// + /// If the value provided is a [`DbFieldValue::Auto`]. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id != 5) + /// ); + /// ``` + #[must_use] + #[expect(clippy::needless_pass_by_value)] + pub fn value(value: T) -> Self { + match value.to_db_field_value() { + DbFieldValue::Value(value) => Self::Value(value), + DbFieldValue::Auto => panic!("Cannot create query with a non-value field"), + } + } + + /// Create a new `AND` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::and( + /// Expr::gt(Expr::field("id"), Expr::value(10)), + /// Expr::lt(Expr::field("id"), Expr::value(20)) + /// ); + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 10 && $id < 20) + /// ); + /// ``` + #[must_use] + pub fn and(lhs: Self, rhs: Self) -> Self { + Self::And(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `OR` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::or( + /// Expr::gt(Expr::field("id"), Expr::value(10)), + /// Expr::lt(Expr::field("id"), Expr::value(20)) + /// ); + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 10 || $id < 20) + /// ); + /// ``` + #[must_use] + pub fn or(lhs: Self, rhs: Self) -> Self { + Self::Or(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == 5) + /// ); + /// ``` + #[must_use] + pub fn eq(lhs: Self, rhs: Self) -> Self { + Self::Eq(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `!=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::ne(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id != 5) + /// ); + /// ``` + #[must_use] + pub fn ne(lhs: Self, rhs: Self) -> Self { + Self::Ne(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `<` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::lt(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id < 5) + /// ); + /// ``` + #[must_use] + pub fn lt(lhs: Self, rhs: Self) -> Self { + Self::Lt(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `<=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::lte(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id <= 5) + /// ); + /// ``` + #[must_use] + pub fn lte(lhs: Self, rhs: Self) -> Self { + Self::Lte(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `>` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::gt(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 5) + /// ); + /// ``` + #[must_use] + pub fn gt(lhs: Self, rhs: Self) -> Self { + Self::Gt(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `>=` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = Expr::gte(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id >= 5) + /// ); + /// ``` + #[must_use] + pub fn gte(lhs: Self, rhs: Self) -> Self { + Self::Gte(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `+` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::add(Expr::field("id_2"), Expr::value(5))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 + 5) + /// ); + /// ``` + #[expect(clippy::should_implement_trait)] + #[must_use] + pub fn add(lhs: Self, rhs: Self) -> Self { + Self::Add(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `-` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::sub(Expr::field("id_2"), Expr::value(5))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 - 5) + /// ); + /// ``` + #[expect(clippy::should_implement_trait)] + #[must_use] + pub fn sub(lhs: Self, rhs: Self) -> Self { + Self::Sub(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `*` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::mul(Expr::field("id_2"), Expr::value(2))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 * 2) + /// ); + /// ``` + #[expect(clippy::should_implement_trait)] + #[must_use] + pub fn mul(lhs: Self, rhs: Self) -> Self { + Self::Mul(Box::new(lhs), Box::new(rhs)) + } + + /// Create a new `/` expression. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// id_2: i32, + /// }; + /// + /// let expr = Expr::eq(Expr::field("id"), Expr::div(Expr::field("id_2"), Expr::value(2))); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == $id_2 / 2) + /// ); + /// ``` + #[expect(clippy::should_implement_trait)] + #[must_use] + pub fn div(lhs: Self, rhs: Self) -> Self { + Self::Div(Box::new(lhs), Box::new(rhs)) + } + + /// Creates an expression that checks whether `lhs` contains `rhs` as a + /// substring, comparing case-sensitively. + /// + /// The search string in `rhs` is treated as a literal: any characters in + /// it that would otherwise be treated as wildcards by [`raw_like`] are + /// escaped automatically, so `"50% off"` matches the literal text + /// `50% off`, not a wildcard pattern. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::contains(Expr::field("name"), Expr::value("test")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.contains("test")) + /// ); + /// ``` + #[must_use] + pub fn contains(lhs: Self, rhs: Self) -> Self { + Self::Contains(Box::new(lhs), Box::new(rhs), CaseSensitivity::Sensitive) + } + + /// Creates an expression that checks whether `lhs` contains `rhs` as a + /// substring, comparing case-insensitively. + /// + /// The case-insensitive counterpart to [`contains`](Self::contains). As + /// with `contains`, the search string in `rhs` is treated as a literal + /// and any wildcard-like characters in it are escaped automatically. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::icontains(Expr::field("name"), Expr::value("TEST")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.icontains("TEST")) + /// ); + /// ``` + #[must_use] + pub fn icontains(lhs: Self, rhs: Self) -> Self { + Self::Contains(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) + } + + /// Creates an expression that checks whether `lhs` starts with `rhs`, + /// comparing case-sensitively. + /// + /// Behaves like [`contains`](Self::contains) but anchored to the start of + /// the value: `rhs` is treated as a literal prefix, with any + /// wildcard-like characters in it escaped automatically. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::starts_with(Expr::field("name"), Expr::value("Mr.")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.starts_with("Mr.")) + /// ); + /// ``` + #[must_use] + pub fn starts_with(lhs: Self, rhs: Self) -> Self { + Self::StartsWith(Box::new(lhs), Box::new(rhs), CaseSensitivity::Sensitive) + } + + /// Creates an expression that checks whether `lhs` starts with `rhs`, + /// comparing case-insensitively. + /// + /// The case-insensitive counterpart to [`starts_with`](Self::starts_with). + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::istarts_with(Expr::field("name"), Expr::value("mr.")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.istarts_with("mr.")) + /// ); + /// ``` + #[must_use] + pub fn istarts_with(lhs: Self, rhs: Self) -> Self { + Self::StartsWith(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) + } + + /// Creates an expression that checks whether `lhs` ends with `rhs`, + /// comparing case-sensitively. + /// + /// Behaves like [`contains`](Self::contains) but anchored to the end of + /// the value: `rhs` is treated as a literal suffix, with any + /// wildcard-like characters in it escaped automatically. + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::ends_with(Expr::field("name"), Expr::value("foo")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.ends_with("foo")) + /// ); + /// ``` + #[must_use] + pub fn ends_with(lhs: Self, rhs: Self) -> Self { + Self::EndsWith(Box::new(lhs), Box::new(rhs), CaseSensitivity::Sensitive) + } + + /// Creates an expression that checks whether `lhs` ends with `rhs`, + /// comparing case-insensitively. + /// + /// The case-insensitive counterpart to [`ends_with`](Self::ends_with). + /// + /// # Example + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// name: String, + /// } + /// + /// let expr = Expr::iends_with(Expr::field("name"), Expr::value("FOO")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $name.iends_with("FOO")) + /// ); + /// ``` + #[must_use] + pub fn iends_with(lhs: Self, rhs: Self) -> Self { + Self::EndsWith(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) + } + + /// Creates an expression that matches `lhs` against a raw pattern in + /// `rhs`, comparing case-sensitively. + /// + /// Unlike [`contains`](Self::contains)/[`starts_with`](Self::starts_with)/ + /// [`ends_with`](Self::ends_with), which escape the search string and + /// wrap it for you, `raw_like` passes `rhs` through as a **pattern you + /// write yourself** — use it when you need a shape those three methods + /// can't express, such as a match with wildcards in the middle, or a + /// fixed-width positional match. + /// + /// # Pattern syntax + /// + /// The pattern in `rhs` uses glob syntax: + /// + /// | Character | Meaning | + /// |-----------|---------| + /// | `*` | matches zero or more of any character | + /// | `?` | matches exactly one character | + /// | `\` | escapes the following character, making it literal | + /// + /// Any other character matches itself literally. + /// + /// # Escaping literal `*`, `?`, or `\` in the pattern + /// + /// If the text you're matching against can itself contain a literal `*` + /// or `?`, escape it with a backslash so it isn't treated as a wildcard. + /// For example, to match values that literally contain the substring + /// `"a * here"`, write the pattern as `"*a \\* here*"`. `\\*` in the Rust + /// string literal produces the two glob characters `\*`, i.e. an escaped, + /// literal asterisk, while the surrounding bare `*` characters remain + /// wildcards. + /// + /// If you don't need this level of control, prefer + /// [`contains`](Self::contains)/[`starts_with`](Self::starts_with)/ + /// [`ends_with`](Self::ends_with), which handle escaping for you + /// automatically based on a plain search string. + /// + /// # Examples + /// + /// Substring match (equivalent to `contains("foo")`, spelled out + /// explicitly): + /// + /// ``` + /// use cot::db::query::expr::Expr; + /// + /// let expr = Expr::raw_like(Expr::field("name"), Expr::value("*foo*")); + /// ``` + /// + /// Prefix match (equivalent to `starts_with("foo")`): + /// + /// ``` + /// use cot::db::query::expr::Expr; + /// + /// let expr = Expr::raw_like(Expr::field("name"), Expr::value("foo*")); + /// ``` + /// + /// **Positional match**: something `contains`/`starts_with`/`ends_with` + /// cannot express at all: match values that are exactly `f`, then any two + /// characters, then `o` (e.g. matches `faXo`, `fooo`, but not `fo` or + /// `faXYo`): + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// code: String, + /// } + /// + /// let expr = Expr::raw_like(Expr::field("code"), Expr::value("f??o")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $code.raw_like("f??o")) + /// ); + /// ``` + /// + /// Wildcards in the middle of a pattern; matching values that contain + /// `foo`, later followed by `bar`, later followed by `baz`, in that + /// order, with anything in between: + /// + /// ``` + /// use cot::db::query::expr::Expr; + /// + /// let expr = Expr::raw_like(Expr::field("path"), Expr::value("*foo*bar*baz*")); + /// ``` + #[must_use] + pub fn raw_like(lhs: Self, rhs: Self) -> Self { + Self::RawLike(Box::new(lhs), Box::new(rhs), CaseSensitivity::Sensitive) + } + + /// Creates an expression that matches `lhs` against a raw pattern in + /// `rhs`, comparing case-insensitively. + /// + /// The case-insensitive counterpart to [`raw_like`](Self::raw_like). See + /// its documentation for the pattern syntax (`*`/`?`/`\`). + /// + /// # Example + /// + /// Case-insensitive positional match — matches `README`, `ReadMe`, + /// `readme`, etc., but not `READMEE` or `REDME`: + /// + /// ``` + /// use cot::db::{model, query}; + /// use cot::db::query::Query; + /// use cot::db::query::expr::Expr; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// filename: String, + /// } + /// + /// let expr = Expr::iraw_like(Expr::field("filename"), Expr::value("re?dme")); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $filename.iraw_like("re?dme")) + /// ); + /// ``` + #[must_use] + pub fn iraw_like(lhs: Self, rhs: Self) -> Self { + Self::RawLike(Box::new(lhs), Box::new(rhs), CaseSensitivity::Insensitive) + } + + /// Returns the expression as a [`sea_query::SimpleExpr`]. + /// + /// # Example + /// + /// ``` + /// use cot::db::query::expr::Expr; + /// use cot::db::{Database, Identifier}; + /// use sea_query::{ExprTrait, IntoColumnRef}; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let db = Database::new("sqlite::memory:").await.unwrap(); + /// let expr = Expr::eq(Expr::field("id"), Expr::value(5)); + /// + /// assert_eq!( + /// expr.as_sea_query_expr(&db).unwrap(), + /// ExprTrait::eq( + /// sea_query::SimpleExpr::Column(Identifier::new("id").into_column_ref()), + /// sea_query::SimpleExpr::Value(sea_query::Value::Int(Some(5))) + /// ) + /// ); + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns [`QueryBuildingError`] if the backend cannot express a given + /// expression. + pub fn as_sea_query_expr( + &self, + sql_dialect: &dyn SqlDialect, + ) -> Result { + match self { + Self::Field(identifier) => Ok((*identifier).into_column_ref().into()), + Self::Value(value) => Ok((*value).clone().into()), + Self::And(lhs, rhs) => lhs + .as_sea_query_expr(sql_dialect) + .and(rhs.as_sea_query_expr(sql_dialect)), + Self::Or(lhs, rhs) => lhs + .as_sea_query_expr(sql_dialect) + .or(rhs.as_sea_query_expr(sql_dialect)), + Self::Eq(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .eq(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Ne(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .ne(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Lt(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .lt(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Lte(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .lte(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Gt(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .gt(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Gte(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .gte(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Add(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .add(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Sub(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .sub(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Mul(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .mul(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Div(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_dialect)? + .div(rhs.as_sea_query_expr(sql_dialect)?)), + Self::Contains(lhs, rhs, case_sensitivity) => { + like::like_expr(sql_dialect, lhs, rhs, LikeMode::Contains, *case_sensitivity) + } + Self::StartsWith(lhs, rhs, case_sensitivity) => like::like_expr( + sql_dialect, + lhs, + rhs, + LikeMode::StartsWith, + *case_sensitivity, + ), + Self::EndsWith(lhs, rhs, case_sensitivity) => { + like::like_expr(sql_dialect, lhs, rhs, LikeMode::EndsWith, *case_sensitivity) + } + Self::RawLike(lhs, rhs, case_sensitivity) => { + like::like_expr(sql_dialect, lhs, rhs, LikeMode::Raw, *case_sensitivity) + } + } + } +} + +/// A reference to a field in a database table. +/// +/// This is used to create expressions that reference a specific column in a +/// table with a specific type. This allows for type-safe creation of queries +/// with some common operators like `=`, `!=`, `+`, `-`, `*`, and `/`. +#[derive(Debug)] +pub struct FieldRef { + identifier: Identifier, + phantom_data: PhantomData, +} + +impl FieldRef { + /// Create a new field reference. + #[must_use] + pub const fn new(identifier: Identifier) -> Self { + Self { + identifier, + phantom_data: PhantomData, + } + } +} + +impl FieldRef { + /// Returns the field reference as an [`Expr`]. + #[must_use] + pub fn as_expr(&self) -> Expr { + Expr::Field(self.identifier) + } +} + +/// A trait for types that can be compared in database expressions. +pub trait ExprEq { + /// Creates an expression that checks if the field is equal to the given + /// value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprEq}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.eq(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id == 5) + /// ); + /// ``` + fn eq>(self, other: V) -> Expr; + + /// Creates an expression that checks if the field is not equal to the given + /// value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprEq}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.ne(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id != 5) + /// ); + /// ``` + fn ne>(self, other: V) -> Expr; +} + +impl ExprEq for FieldRef { + fn eq>(self, other: V) -> Expr { + Expr::eq(self.as_expr(), Expr::value(other.into_field())) + } + + fn ne>(self, other: V) -> Expr { + Expr::ne(self.as_expr(), Expr::value(other.into_field())) + } +} + +/// A trait for database types that can be added to each other. +pub trait ExprAdd { + /// Creates an expression that adds the field to the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::{Expr, ExprAdd}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.add(5); + /// + /// assert_eq!( + /// >::new().filter(Expr::eq(Expr::field("id"), expr)), + /// query!(MyModel, $id == $id + 5) + /// ); + /// ``` + fn add>(self, other: V) -> Expr; +} + +/// A trait for database types that can be subtracted from each other. +pub trait ExprSub { + /// Creates an expression that subtracts the field from the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprSub}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.sub(5); + /// + /// assert_eq!( + /// >::new().filter(Expr::eq(Expr::field("id"), expr)), + /// query!(MyModel, $id == $id - 5) + /// ); + /// ``` + fn sub>(self, other: V) -> Expr; +} + +/// A trait for database types that can be multiplied by each other. +pub trait ExprMul { + /// Creates an expression that multiplies the field by the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprMul}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.mul(2); + /// + /// assert_eq!( + /// >::new().filter(Expr::eq(Expr::field("id"), expr)), + /// query!(MyModel, $id == $id * 2) + /// ); + /// ``` + fn mul>(self, other: V) -> Expr; +} + +/// A trait for database types that can be divided by each other. +pub trait ExprDiv { + /// Creates an expression that divides the field by the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprDiv}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.div(2); + /// + /// assert_eq!( + /// >::new().filter(Expr::eq(Expr::field("id"), expr)), + /// query!(MyModel, $id == $id / 2) + /// ); + /// ``` + fn div>(self, other: V) -> Expr; +} + +/// A trait for database types that can be ordered. +pub trait ExprOrd { + /// Creates an expression that checks if the field is less than the given + /// value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprOrd}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.lt(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id < 5) + /// ); + /// ``` + fn lt>(self, other: V) -> Expr; + /// Creates an expression that checks if the field is less than or equal to + /// the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::expr::{Expr, ExprOrd}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.lte(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id <= 5) + /// ); + /// ``` + fn lte>(self, other: V) -> Expr; + + /// Creates an expression that checks if the field is greater than the given + /// value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::{Expr, ExprOrd}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.gt(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id > 5) + /// ); + /// ``` + fn gt>(self, other: V) -> Expr; + + /// Creates an expression that checks if the field is greater than or equal + /// to the given value. + /// + /// # Examples + /// + /// ``` + /// use cot::db::query::Query; + /// use cot::db::query::{Expr, ExprOrd}; + /// use cot::db::{model, query}; + /// + /// #[model] + /// struct MyModel { + /// #[model(primary_key)] + /// id: i32, + /// }; + /// + /// let expr = ::Fields::id.gte(5); + /// + /// assert_eq!( + /// >::new().filter(expr), + /// query!(MyModel, $id >= 5) + /// ); + /// ``` + fn gte>(self, other: V) -> Expr; +} + +impl ExprOrd for FieldRef { + fn lt>(self, other: V) -> Expr { + Expr::lt(self.as_expr(), Expr::value(other.into_field())) + } + + fn lte>(self, other: V) -> Expr { + Expr::lte(self.as_expr(), Expr::value(other.into_field())) + } + + fn gt>(self, other: V) -> Expr { + Expr::gt(self.as_expr(), Expr::value(other.into_field())) + } + + fn gte>(self, other: V) -> Expr { + Expr::gte(self.as_expr(), Expr::value(other.into_field())) + } +} + +/// A marker trait that represents the full set of query-translation +/// capabilities a database backend may support. +pub trait SqlDialect: LikeDialect {} + +impl SqlDialect for T where T: LikeDialect {} + +macro_rules! impl_expr { + ($ty:ty, $trait:ident, $method:ident) => { + impl $trait<$ty> for FieldRef<$ty> { + fn $method>(self, other: V) -> Expr { + Expr::$method(self.as_expr(), Expr::value(other.into())) + } + } + }; +} + +macro_rules! impl_num_expr { + ($ty:ty) => { + impl_expr!($ty, ExprAdd, add); + impl_expr!($ty, ExprSub, sub); + impl_expr!($ty, ExprMul, mul); + impl_expr!($ty, ExprDiv, div); + }; +} + +impl_num_expr!(i8); +impl_num_expr!(i16); +impl_num_expr!(i32); +impl_num_expr!(i64); +impl_num_expr!(u8); +impl_num_expr!(u16); +impl_num_expr!(u32); +impl_num_expr!(u64); +impl_num_expr!(f32); +impl_num_expr!(f64); + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn expr_field() { + let expr = Expr::field("name"); + if let Expr::Field(identifier) = expr { + assert_eq!(identifier.to_string(), "name"); + } else { + panic!("Expected Expr::Field"); + } + } + + #[test] + fn expr_value() { + let expr = Expr::value(30); + if let Expr::Value(value) = expr { + assert_eq!(value.to_string(), "30"); + } else { + panic!("Expected Expr::Value"); + } + } + + macro_rules! test_expr_constructor { + ($test_name:ident, $match:ident, $constructor:ident) => { + #[test] + fn $test_name() { + let expr = Expr::$constructor(Expr::field("name"), Expr::value("John")); + if let Expr::$match(lhs, rhs) = expr { + assert!(matches!(*lhs, Expr::Field(_))); + assert!(matches!(*rhs, Expr::Value(_))); + } else { + panic!(concat!("Expected Expr::", stringify!($match))); + } + } + }; + } + + test_expr_constructor!(expr_and, And, and); + test_expr_constructor!(expr_or, Or, or); + test_expr_constructor!(expr_eq, Eq, eq); + test_expr_constructor!(expr_ne, Ne, ne); + test_expr_constructor!(expr_lt, Lt, lt); + test_expr_constructor!(expr_lte, Lte, lte); + test_expr_constructor!(expr_gt, Gt, gt); + test_expr_constructor!(expr_gte, Gte, gte); + test_expr_constructor!(expr_add, Add, add); + test_expr_constructor!(expr_sub, Sub, sub); + test_expr_constructor!(expr_mul, Mul, mul); + test_expr_constructor!(expr_div, Div, div); +} diff --git a/cot/src/db/query/expr/like.rs b/cot/src/db/query/expr/like.rs new file mode 100644 index 000000000..1b864726a --- /dev/null +++ b/cot/src/db/query/expr/like.rs @@ -0,0 +1,153 @@ +//! Database expressions for pattern-matching. + +use cot::db::ToDbFieldValue; +use cot::db::query::expr::{FieldRef, SqlDialect}; +use cot::db::query::{Expr, IntoField, QueryBuildingError}; +use sea_query::{ExprTrait, SimpleExpr}; + +pub(crate) const LIKE_ESCAPE_CHAR: char = '\\'; + +/// A trait for database types that support literal-substring pattern +/// matching. +pub trait ExprLike { + /// Checks if the field contains `other` as a literal substring, + /// comparing case-sensitively. + /// + /// See [`Expr::contains`] for the underlying semantics. + fn contains>(self, other: V) -> Expr; + + /// Checks if the field starts with `other`, comparing + /// case-sensitively. + /// + /// See [`Expr::starts_with`] for the underlying semantics. + fn starts_with>(self, other: V) -> Expr; + + /// Checks if the field ends with `other`, comparing case-sensitively. + /// + /// See [`Expr::ends_with`] for the underlying semantics. + fn ends_with>(self, other: V) -> Expr; +} + +impl ExprLike for FieldRef { + fn contains>(self, other: V) -> Expr { + Expr::contains(self.as_expr(), Expr::value(other.into_field())) + } + fn starts_with>(self, other: V) -> Expr { + Expr::starts_with(self.as_expr(), Expr::value(other.into_field())) + } + fn ends_with>(self, other: V) -> Expr { + Expr::ends_with(self.as_expr(), Expr::value(other.into_field())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum LikeMode { + Contains, + StartsWith, + EndsWith, + Raw, +} + +fn escape_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for c in value.chars() { + if matches!(c, '*' | '?' | LIKE_ESCAPE_CHAR) { + escaped.push(LIKE_ESCAPE_CHAR); + } + escaped.push(c); + } + escaped +} + +pub(crate) fn to_sql_like(glob: &str) -> String { + let mut escaped = String::with_capacity(glob.len()); + let mut chars = glob.chars(); + while let Some(ch) = chars.next() { + match ch { + LIKE_ESCAPE_CHAR => { + if let Some(ch) = chars.next() { + push_like_literal(&mut escaped, ch); + } + } + '*' => escaped.push('%'), + '?' => escaped.push('_'), + other => push_like_literal(&mut escaped, other), + } + } + escaped +} + +fn push_like_literal(out: &mut String, ch: char) { + if matches!(ch, '%' | '_' | LIKE_ESCAPE_CHAR) { + out.push(LIKE_ESCAPE_CHAR); + } + out.push(ch); +} + +pub(crate) fn like_expr( + sql_dialect: &dyn SqlDialect, + lhs: &Expr, + rhs: &Expr, + mode: LikeMode, + case_sensitivity: CaseSensitivity, +) -> Result { + let lhs_expr = lhs.as_sea_query_expr(sql_dialect)?; + + let Expr::Value(value) = rhs else { + return Ok(sea_query::Expr::val(1).eq(0)); + }; + + let sea_value: sea_query::Value = value.clone(); + let sea_query::Value::String(Some(text)) = sea_value else { + return Ok(sea_query::Expr::val(1).eq(0)); + }; + let glob_pattern = match mode { + LikeMode::Contains => format!("*{}*", escape_literal(&text)), + LikeMode::EndsWith => format!("*{}", escape_literal(&text)), + LikeMode::StartsWith => format!("{}*", escape_literal(&text)), + LikeMode::Raw => text.clone(), + }; + + sql_dialect.like_expr(lhs_expr, &glob_pattern, case_sensitivity) +} + +/// Whether a pattern-matching expression should consider letter case when +/// comparing text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CaseSensitivity { + /// Matching considers letter case (`"Foo"` does not match `"foo"`). + Sensitive, + /// Matching ignores letter case (`"Foo"` matches `"foo"`). + Insensitive, +} + +/// Translates Cot's pattern-matching query expressions (`contains`, +/// `starts_with`, `ends_with`, `raw_like`, and their case-insensitive and +/// `i`-prefixed counterparts) into a backend-specific `sea_query` +/// expression. +/// +/// Each of Cot's built-in database backends implements this trait; a +/// custom `sea_query`-based backend can implement it too to get correct, +/// dialect-appropriate translation of pattern-matching expressions without +/// needing to know anything about the individual `Expr` variants that +/// produce them. An implementor only ever sees a single, already-built +/// left-hand-side expression, a pattern already expressed in Cot's +/// canonical glob syntax (`*`/`?`/`\` — see [`Expr::raw_like`]), and the +/// requested [`CaseSensitivity`]. +pub trait LikeDialect { + /// Builds the `sea_query` expression that checks whether `lhs` + /// matches `glob_pattern`, honoring `case_sensitivity`. + /// + /// # Errors + /// + /// Returns [`QueryBuildingError::UnsupportedExpr`] if the backend cannot + /// express pattern matching at all, or cannot express the requested + /// [`CaseSensitivity`] variant. Backends that support this capability + /// should never need to return any other error variant from this method. + fn like_expr( + &self, + lhs: SimpleExpr, + glob_pattern: &str, + case_sensitivity: CaseSensitivity, + ) -> Result; +} diff --git a/cot/tests/db.rs b/cot/tests/db.rs index 0eb4840f1..8f7cac81d 100644 --- a/cot/tests/db.rs +++ b/cot/tests/db.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use cot::auth::PasswordHash; use cot::common_types::{Email, Password, Url}; use cot::db::migrations::{Field, Operation}; -use cot::db::query::ExprEq; +use cot::db::query::expr::ExprEq; use cot::db::{ Auto, Database, DatabaseError, DatabaseField, ForeignKey, ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy, Identifier, LimitedString, Model, model, query, diff --git a/docs/databases/queries.md b/docs/databases/queries.md index 622d075fe..b9cd43f02 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -257,12 +257,12 @@ async fn get_customer(db: Database) -> cot::Result<()> { The [`query!`](macro@cot::db::query) macro returns a [`Query`](struct@cot::db::query::Query) object, on which you can call terminal methods (such as [`get`](struct@cot::db::query::Query#method.get) which returns the first matching result, and [`all`](struct@cot::db::query::Query#method.all) which returns all matching results) to retrieve the final results. ### Using the Query struct -The [`query!`](macro@cot::db::query) macro is syntactic sugar for manually constructing a [`Query`](struct@cot::db::query::Query) with [`Expr`](enum@cot::db::query::Expr) expressions. The [`Query`](struct@cot::db::query::Query) object can be accessed directly by calling the [`objects`](trait@cot::db::Model#method.objects) method on the model, and filtered using the [`filter`](struct@cot::db::query::Query#method.filter) method. +The [`query!`](macro@cot::db::query) macro is syntactic sugar for manually constructing a [`Query`](struct@cot::db::query::Query) with [`Expr`](enum@cot::db::query::expr::Expr) expressions. The [`Query`](struct@cot::db::query::Query) object can be accessed directly by calling the [`objects`](trait@cot::db::Model#method.objects) method on the model, and filtered using the [`filter`](struct@cot::db::query::Query#method.filter) method. You may prefer this approach when you need more control over how expressions are constructed. The example below is equivalent to the one above: ```rust use cot::db::Database; -use cot::db::query::Expr; +use cot::db::query::expr::Expr; # #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } async fn get_customer(db: Database) -> cot::Result<()> { @@ -272,14 +272,14 @@ async fn get_customer(db: Database) -> cot::Result<()> { } ``` -The [`filter`](struct@cot::db::query::Query#method.filter) method takes a [`filter expression`](enum@cot::db::query::Expr). In the example above, the expression `Expr::eq(Expr::field("id"), Expr::value("5"))` is evaluated as `id = 5`. +The [`filter`](struct@cot::db::query::Query#method.filter) method takes a [`filter expression`](enum@cot::db::query::expr::Expr). In the example above, the expression `Expr::eq(Expr::field("id"), Expr::value("5"))` is evaluated as `id = 5`. ### Retrieving all objects One way to retrieve all objects of a model is to call the [`all`](struct@cot::db::query::Query#method.all) method after filtering the query results. ```rust use cot::db::Database; -use cot::db::query::Expr; +use cot::db::query::expr::Expr; # #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } async fn get_all_customers(db: Database) -> cot::Result<()> { @@ -296,7 +296,7 @@ The [`filter`](struct@cot::db::query::Query#method.filter) method returns a new ```rust use cot::db::Database; -use cot::db::query::Expr; +use cot::db::query::expr::Expr; # #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } async fn get_customers(db: Database) -> cot::Result<()> { From c829176b69d6553484fa44083fb42e7ba40b3d3c Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 4 Jul 2026 21:41:51 +0000 Subject: [PATCH 04/24] add unit tests. Rename to LikeExprBuilder and SqlQueryBuilder --- cot-macros/src/query.rs | 17 +- cot-macros/tests/query.rs | 136 +++++++++++++++- cot/src/db.rs | 4 +- cot/src/db/impl_mysql.rs | 15 +- cot/src/db/impl_postgres.rs | 9 +- cot/src/db/impl_sqlite.rs | 14 +- cot/src/db/query.rs | 6 +- cot/src/db/query/expr.rs | 68 ++++---- cot/src/db/query/expr/like.rs | 58 ++++++- cot/tests/db.rs | 295 ++++++++++++++++++++++++++++++++++ 10 files changed, 557 insertions(+), 65 deletions(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index ccb3e95b0..eb549a54c 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -52,10 +52,10 @@ impl StringMethod { "iends_with" => Some(Self::EndsWith { case_sensitive: false, }), - "raw" => Some(Self::Raw { + "raw_like" => Some(Self::Raw { case_sensitive: true, }), - "iraw" => Some(Self::Raw { + "iraw_like" => Some(Self::Raw { case_sensitive: false, }), _ => None, @@ -84,10 +84,10 @@ impl StringMethod { } => "iends_with", Self::Raw { case_sensitive: true, - } => "raw", + } => "raw_like", Self::Raw { case_sensitive: false, - } => "iraw", + } => "iraw_like", }; format_ident!("{name}") } @@ -100,8 +100,8 @@ impl StringMethod { "istarts_with", "ends_with", "iends_with", - "raw", - "iraw", + "raw_like", + "iraw_like", ] } } @@ -254,6 +254,11 @@ fn handle_string_method( }; } + // If the receiver isn't a bare field reference (e.g. a composite + // expression like `$a + $b`), fall back to the untyped `Expr` constructor. + // This bypasses compile-time type checking, so a mismatched type will + // only surface as a runtime error (or, in the worst case, a + // silently-wrong query) rather than a compile error. let receiver_tokens = expr_to_tokens(model_name, receiver); quote! { #crate_name::db::query::expr::Expr::#method_ident( diff --git a/cot-macros/tests/query.rs b/cot-macros/tests/query.rs index f88e65cb6..4b78671f9 100644 --- a/cot-macros/tests/query.rs +++ b/cot-macros/tests/query.rs @@ -1,5 +1,5 @@ use cot::db::query::Query; -use cot::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprEq, ExprMul, ExprOrd, ExprSub}; +use cot::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprEq, ExprLike, ExprMul, ExprOrd, ExprSub}; use cot::db::{model, query}; #[model] @@ -8,8 +8,10 @@ struct MyModel { #[model(primary_key)] id: i32, name: String, + title: String, price: i64, quantity: i64, + valid: bool, } #[test] @@ -290,3 +292,135 @@ fn test_query_path_access() { query!(MyModel, $id == constants::ID) ); } + +#[test] +fn test_query_string_methods_on_bare_field() { + assert_eq!( + Query::::new().filter(ExprLike::contains( + ::Fields::name, + "foo" + )), + query!(MyModel, $name.contains("foo")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::icontains( + ::Fields::name, + "FOO" + )), + query!(MyModel, $name.icontains("FOO")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::starts_with( + ::Fields::name, + "foo" + )), + query!(MyModel, $name.starts_with("foo")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::istarts_with( + ::Fields::name, + "FOO" + )), + query!(MyModel, $name.istarts_with("FOO")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::ends_with( + ::Fields::name, + "bar" + )), + query!(MyModel, $name.ends_with("bar")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::iends_with( + ::Fields::name, + "BAR" + )), + query!(MyModel, $name.iends_with("BAR")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::raw_like( + ::Fields::name, + "f??o" + )), + query!(MyModel, $name.raw_like("f??o")) + ); + + assert_eq!( + Query::::new().filter(ExprLike::iraw_like( + ::Fields::name, + "F??O" + )), + query!(MyModel, $name.iraw_like("F??O")) + ); +} + +#[test] +fn test_query_string_method_composite_receiver_falls_back_to_expr() { + assert_eq!( + Query::::new().filter(Expr::contains( + Expr::add( + ::Fields::quantity.as_expr(), + ::Fields::id.as_expr() + ), + Expr::value("50") + )), + query!(MyModel, ($quantity + $id).contains("50")) + ); + + assert_eq!( + Query::::new().filter(Expr::ends_with( + Expr::add( + ::Fields::quantity.as_expr(), + ::Fields::id.as_expr() + ), + Expr::value("0") + )), + query!(MyModel, ($quantity + $id).ends_with("0")) + ); +} + +#[test] +fn test_query_string_method_combined_with_boolean_ops() { + assert_eq!( + Query::::new().filter(Expr::and( + ExprLike::contains(::Fields::name, "foo"), + Expr::gt( + ::Fields::id.as_expr(), + Expr::value(0) + ) + )), + query!(MyModel, $name.contains("foo") && $id > 0) + ); +} + +#[test] +fn test_query_string_method_string_concat_field_refs() { + assert_eq!( + Query::::new().filter(Expr::contains( + Expr::add( + ::Fields::name.as_expr(), + ::Fields::title.as_expr() + ), + Expr::value("foo") + )), + query!(MyModel, ($name + $title).contains("foo")) + ); +} + +#[test] +fn test_query_string_method_non_field_receiver_call() { + let allowed_names = &["foo", "bar"]; + assert_eq!( + Query::::new().filter(Expr::eq( + ::Fields::valid.as_expr(), + Expr::value(true) + )), + query!(MyModel, $valid == allowed_names.contains(&"foo")) + ); +} diff --git a/cot/src/db.rs b/cot/src/db.rs index 11473d951..3938de233 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -424,7 +424,7 @@ use derive_more::{Debug, Deref, Display}; #[cfg(test)] use mockall::automock; use query::Query; -use query::expr::like::{CaseSensitivity, LikeDialect}; +use query::expr::like::{CaseSensitivity, LikeExprBuilder}; pub use relations::{ForeignKey, ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy}; use sea_query::{ ColumnRef, ExprTrait, Iden, IntoColumnRef, OnConflict, ReturningClause, SchemaStatementBuilder, @@ -1988,7 +1988,7 @@ impl ColumnTypeMapper for Database { } } -impl LikeDialect for Database { +impl LikeExprBuilder for Database { fn like_expr( &self, lhs: SimpleExpr, diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 34d0ff1e3..b8a3c7f7c 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -1,10 +1,11 @@ //! Database interface implementation – MySQL backend. -use sea_query::{ExprTrait, SimpleExpr}; +use cot::db::query::expr::like::LIKE_ESCAPE_CHAR; +use sea_query::{ExprTrait, LikeExpr, SimpleExpr}; use crate::db::ColumnType; use crate::db::query::QueryBuildingError; -use crate::db::query::expr::like::{CaseSensitivity, LikeDialect, to_sql_like}; +use crate::db::query::expr::like::{CaseSensitivity, LikeExprBuilder, to_sql_like}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabaseMySql: sqlx::mysql::MySql, sqlx::mysql::MySqlPool, MySqlRow, MySqlValueRef, sea_query::MysqlQueryBuilder); @@ -40,14 +41,14 @@ impl DatabaseMySql { } } -impl LikeDialect for DatabaseMySql { +impl LikeExprBuilder for DatabaseMySql { fn like_expr( &self, lhs: SimpleExpr, glob_pattern: &str, case_sensitivity: CaseSensitivity, ) -> Result { - let glob = to_sql_like(glob_pattern); + let like = LikeExpr::new(to_sql_like(glob_pattern).to_lowercase()).escape(LIKE_ESCAPE_CHAR); match case_sensitivity { CaseSensitivity::Sensitive => { @@ -56,12 +57,10 @@ impl LikeDialect for DatabaseMySql { sea_query::BinOper::Custom("COLLATE"), sea_query::Expr::cust("utf8mb4_bin"), ) - .like(glob); + .like(like); Ok(expr) } - CaseSensitivity::Insensitive => { - Ok(sea_query::Func::lower(lhs).like(glob.to_lowercase())) - } + CaseSensitivity::Insensitive => Ok(sea_query::Func::lower(lhs).like(like)), } } } diff --git a/cot/src/db/impl_postgres.rs b/cot/src/db/impl_postgres.rs index fee6c6d9d..1049f89eb 100644 --- a/cot/src/db/impl_postgres.rs +++ b/cot/src/db/impl_postgres.rs @@ -1,10 +1,11 @@ //! Database interface implementation – PostgreSQL backend. use cot::db::query::QueryBuildingError; +use cot::db::query::expr::like::LIKE_ESCAPE_CHAR; use sea_query::extension::postgres::PgExpr; -use sea_query::{ExprTrait, SimpleExpr}; +use sea_query::{ExprTrait, LikeExpr, SimpleExpr}; -use crate::db::query::expr::like::{CaseSensitivity, LikeDialect, to_sql_like}; +use crate::db::query::expr::like::{CaseSensitivity, LikeExprBuilder, to_sql_like}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabasePostgres: sqlx::postgres::Postgres, sqlx::postgres::PgPool, PostgresRow, PostgresValueRef, sea_query::PostgresQueryBuilder); @@ -48,14 +49,14 @@ impl DatabasePostgres { } } -impl LikeDialect for DatabasePostgres { +impl LikeExprBuilder for DatabasePostgres { fn like_expr( &self, lhs: SimpleExpr, glob_pattern: &str, case_sensitivity: CaseSensitivity, ) -> Result { - let glob = to_sql_like(glob_pattern); + let glob = LikeExpr::new(to_sql_like(glob_pattern)).escape(LIKE_ESCAPE_CHAR); match case_sensitivity { CaseSensitivity::Sensitive => Ok(lhs.like(glob)), diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 64b74945b..136f6ff51 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -1,11 +1,13 @@ //! Database interface implementation – SQLite backend. use sea_query::extension::sqlite::SqliteExpr; -use sea_query::{ExprTrait, SimpleExpr}; +use sea_query::{ExprTrait, LikeExpr, SimpleExpr}; use sea_query_sqlx::SqlxValues; use crate::db::query::QueryBuildingError; -use crate::db::query::expr::like::{CaseSensitivity, LIKE_ESCAPE_CHAR, LikeDialect}; +use crate::db::query::expr::like::{ + CaseSensitivity, LIKE_ESCAPE_CHAR, LikeExprBuilder, to_sql_like, +}; use crate::db::sea_query_db::impl_sea_query_db_backend; impl_sea_query_db_backend!(DatabaseSqlite: sqlx::sqlite::Sqlite, sqlx::sqlite::SqlitePool, SqliteRow, SqliteValueRef, sea_query::SqliteQueryBuilder); @@ -51,6 +53,8 @@ impl DatabaseSqlite { Self::push_glob_literal(&mut escaped, ch); } } + '*' => escaped.push('*'), + '?' => escaped.push('?'), other => Self::push_glob_literal(&mut escaped, other), } } @@ -68,7 +72,7 @@ impl DatabaseSqlite { } } -impl LikeDialect for DatabaseSqlite { +impl LikeExprBuilder for DatabaseSqlite { fn like_expr( &self, lhs: SimpleExpr, @@ -81,7 +85,9 @@ impl LikeDialect for DatabaseSqlite { Ok(lhs.glob(glob)) } CaseSensitivity::Insensitive => { - Ok(sea_query::Func::lower(lhs).like(glob_pattern.to_lowercase())) + let like = LikeExpr::new(to_sql_like(&glob_pattern.to_lowercase())) + .escape(LIKE_ESCAPE_CHAR); + Ok(sea_query::Func::lower(lhs).like(like)) } } } diff --git a/cot/src/db/query.rs b/cot/src/db/query.rs index 68aadb2f7..253ffb4c9 100644 --- a/cot/src/db/query.rs +++ b/cot/src/db/query.rs @@ -9,7 +9,7 @@ use sea_query::ExprTrait; use thiserror::Error; use crate::db; -use crate::db::query::expr::SqlDialect; +use crate::db::query::expr::SqlQueryBuilder; pub use crate::db::query::expr::{Expr, ExprAdd, ExprDiv, ExprMul, ExprOrd, ExprSub}; use crate::db::{ Auto, Database, DatabaseBackend, ForeignKey, Model, StatementResult, ToDbFieldValue, @@ -249,10 +249,10 @@ impl Query { pub(super) fn add_filter_to_statement( &self, statement: &mut S, - db: &dyn SqlDialect, + sql_builder: &dyn SqlQueryBuilder, ) -> Result<(), QueryBuildingError> { if let Some(filter) = &self.filter { - statement.and_where(filter.as_sea_query_expr(db)?); + statement.and_where(filter.as_sea_query_expr(sql_builder)?); } Ok(()) } diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index ede8aca59..8946ccd95 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -6,7 +6,7 @@ use std::marker::PhantomData; use cot::db::query::{IntoField, QueryBuildingError}; use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; pub use like::ExprLike; -use like::{CaseSensitivity, LikeDialect, LikeMode}; +use like::{CaseSensitivity, LikeExprBuilder, LikeMode}; use sea_query::{ExprTrait, IntoColumnRef, SimpleExpr}; /// An expression that can be used to filter, update, or delete rows. @@ -1227,62 +1227,62 @@ impl Expr { /// expression. pub fn as_sea_query_expr( &self, - sql_dialect: &dyn SqlDialect, + sql_builder: &dyn SqlQueryBuilder, ) -> Result { match self { Self::Field(identifier) => Ok((*identifier).into_column_ref().into()), Self::Value(value) => Ok((*value).clone().into()), - Self::And(lhs, rhs) => lhs - .as_sea_query_expr(sql_dialect) - .and(rhs.as_sea_query_expr(sql_dialect)), - Self::Or(lhs, rhs) => lhs - .as_sea_query_expr(sql_dialect) - .or(rhs.as_sea_query_expr(sql_dialect)), + Self::And(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_builder)? + .and(rhs.as_sea_query_expr(sql_builder)?)), + Self::Or(lhs, rhs) => Ok(lhs + .as_sea_query_expr(sql_builder)? + .or(rhs.as_sea_query_expr(sql_builder)?)), Self::Eq(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .eq(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .eq(rhs.as_sea_query_expr(sql_builder)?)), Self::Ne(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .ne(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .ne(rhs.as_sea_query_expr(sql_builder)?)), Self::Lt(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .lt(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .lt(rhs.as_sea_query_expr(sql_builder)?)), Self::Lte(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .lte(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .lte(rhs.as_sea_query_expr(sql_builder)?)), Self::Gt(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .gt(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .gt(rhs.as_sea_query_expr(sql_builder)?)), Self::Gte(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .gte(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .gte(rhs.as_sea_query_expr(sql_builder)?)), Self::Add(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .add(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .add(rhs.as_sea_query_expr(sql_builder)?)), Self::Sub(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .sub(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .sub(rhs.as_sea_query_expr(sql_builder)?)), Self::Mul(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .mul(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .mul(rhs.as_sea_query_expr(sql_builder)?)), Self::Div(lhs, rhs) => Ok(lhs - .as_sea_query_expr(sql_dialect)? - .div(rhs.as_sea_query_expr(sql_dialect)?)), + .as_sea_query_expr(sql_builder)? + .div(rhs.as_sea_query_expr(sql_builder)?)), Self::Contains(lhs, rhs, case_sensitivity) => { - like::like_expr(sql_dialect, lhs, rhs, LikeMode::Contains, *case_sensitivity) + like::like_expr(sql_builder, lhs, rhs, LikeMode::Contains, *case_sensitivity) } Self::StartsWith(lhs, rhs, case_sensitivity) => like::like_expr( - sql_dialect, + sql_builder, lhs, rhs, LikeMode::StartsWith, *case_sensitivity, ), Self::EndsWith(lhs, rhs, case_sensitivity) => { - like::like_expr(sql_dialect, lhs, rhs, LikeMode::EndsWith, *case_sensitivity) + like::like_expr(sql_builder, lhs, rhs, LikeMode::EndsWith, *case_sensitivity) } Self::RawLike(lhs, rhs, case_sensitivity) => { - like::like_expr(sql_dialect, lhs, rhs, LikeMode::Raw, *case_sensitivity) + like::like_expr(sql_builder, lhs, rhs, LikeMode::Raw, *case_sensitivity) } } } @@ -1611,9 +1611,9 @@ impl ExprOrd for FieldRef { /// A marker trait that represents the full set of query-translation /// capabilities a database backend may support. -pub trait SqlDialect: LikeDialect {} +pub trait SqlQueryBuilder: LikeExprBuilder {} -impl SqlDialect for T where T: LikeDialect {} +impl SqlQueryBuilder for T where T: LikeExprBuilder {} macro_rules! impl_expr { ($ty:ty, $trait:ident, $method:ident) => { diff --git a/cot/src/db/query/expr/like.rs b/cot/src/db/query/expr/like.rs index 1b864726a..07777b961 100644 --- a/cot/src/db/query/expr/like.rs +++ b/cot/src/db/query/expr/like.rs @@ -1,7 +1,7 @@ //! Database expressions for pattern-matching. use cot::db::ToDbFieldValue; -use cot::db::query::expr::{FieldRef, SqlDialect}; +use cot::db::query::expr::{FieldRef, SqlQueryBuilder}; use cot::db::query::{Expr, IntoField, QueryBuildingError}; use sea_query::{ExprTrait, SimpleExpr}; @@ -16,28 +16,80 @@ pub trait ExprLike { /// See [`Expr::contains`] for the underlying semantics. fn contains>(self, other: V) -> Expr; + /// Checks if the field contains `other` as a literal substring. + /// This is the case-insensitive counterpart of [`Self::contains`]. + /// + /// See [`Expr::contains`] for the underlying semantics. + fn icontains>(self, other: V) -> Expr; + /// Checks if the field starts with `other`, comparing /// case-sensitively. /// /// See [`Expr::starts_with`] for the underlying semantics. fn starts_with>(self, other: V) -> Expr; + /// Checks if the field starts with `other`. + /// This is the case-insensitive counterpart of [`Self::starts_with`]. + /// + /// See [`Expr::starts_with`] for the underlying semantics. + fn istarts_with>(self, other: V) -> Expr; + /// Checks if the field ends with `other`, comparing case-sensitively. /// /// See [`Expr::ends_with`] for the underlying semantics. fn ends_with>(self, other: V) -> Expr; + + /// Checks if the field ends with `other`. + /// This is the case-insensitive counterpart of [`Self::ends_with`]. + /// + /// See [`Expr::ends_with`] for the underlying semantics. + fn iends_with>(self, other: V) -> Expr; + + /// Matches an expression against the raw pattern provided in `other`, + /// matching case-sensitively. + /// + /// See [`Expr::raw_like`] for the underlying semantics. + fn raw_like>(self, other: V) -> Expr; + + /// Matches an expression against the raw pattern provided in `other`. + /// This is the case-insensitive counterpart of [`Self::raw_like`]. + /// + /// See [`Expr::iraw_like`] for the underlying semantics. + fn iraw_like>(self, other: V) -> Expr; } impl ExprLike for FieldRef { fn contains>(self, other: V) -> Expr { Expr::contains(self.as_expr(), Expr::value(other.into_field())) } + + fn icontains>(self, other: V) -> Expr { + Expr::icontains(self.as_expr(), Expr::value(other.into_field())) + } + fn starts_with>(self, other: V) -> Expr { Expr::starts_with(self.as_expr(), Expr::value(other.into_field())) } + + fn istarts_with>(self, other: V) -> Expr { + Expr::istarts_with(self.as_expr(), Expr::value(other.into_field())) + } + fn ends_with>(self, other: V) -> Expr { Expr::ends_with(self.as_expr(), Expr::value(other.into_field())) } + + fn iends_with>(self, other: V) -> Expr { + Expr::iends_with(self.as_expr(), Expr::value(other.into_field())) + } + + fn raw_like>(self, other: V) -> Expr { + Expr::raw_like(self.as_expr(), Expr::value(other.into_field())) + } + + fn iraw_like>(self, other: V) -> Expr { + Expr::iraw_like(self.as_expr(), Expr::value(other.into_field())) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -85,7 +137,7 @@ fn push_like_literal(out: &mut String, ch: char) { } pub(crate) fn like_expr( - sql_dialect: &dyn SqlDialect, + sql_dialect: &dyn SqlQueryBuilder, lhs: &Expr, rhs: &Expr, mode: LikeMode, @@ -134,7 +186,7 @@ pub enum CaseSensitivity { /// left-hand-side expression, a pattern already expressed in Cot's /// canonical glob syntax (`*`/`?`/`\` — see [`Expr::raw_like`]), and the /// requested [`CaseSensitivity`]. -pub trait LikeDialect { +pub trait LikeExprBuilder { /// Builds the `sea_query` expression that checks whether `lhs` /// matches `glob_pattern`, honoring `case_sensitivity`. /// diff --git a/cot/tests/db.rs b/cot/tests/db.rs index 8f7cac81d..c9c7071b5 100644 --- a/cot/tests/db.rs +++ b/cot/tests/db.rs @@ -1070,3 +1070,298 @@ async fn bulk_insert_with_fixed_pk(test_db: &mut TestDatabase) { .unwrap(); assert_eq!(model300.name, "test300"); } + +async fn seed(test_db: &TestDatabase, names: &[&str]) { + let mut models: Vec = names + .iter() + .map(|n| TestModel { + id: Auto::auto(), + name: (*n).to_owned(), + }) + .collect(); + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); +} + +fn names_of(objects: &[TestModel]) -> Vec<&str> { + objects.iter().map(|o| o.name.as_str()).collect() +} + +#[cot_macros::dbtest] +async fn model_query_contains_case_sensitive(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; + + let objects = query!(TestModel, $name.contains("oo")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["foo", "Foo"]); + + let objects = query!(TestModel, $name.contains("fo")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["foo"]); + + let objects = query!(TestModel, $name.contains("bar")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["bar"]); + + let objects = query!(TestModel, $name.contains("xyz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); + + let objects = query!(TestModel, $name.contains("")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(objects.len(), 5); +} + +#[cot_macros::dbtest] +async fn model_query_icontains(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foo", "Foo", "fOO", "FOO", "bar"]).await; + + let mut objects = query!(TestModel, $name.icontains("OO")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["foo", "Foo", "fOO", "FOO"]); + + let objects = query!(TestModel, $name.icontains("xyz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_starts_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foobar", "Foobar", "barfoo", "foo"]).await; + + let objects = query!(TestModel, $name.starts_with("foo")) + .all(&**test_db) + .await + .unwrap(); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["foo", "foobar"]); + + let objects = query!(TestModel, $name.starts_with("bar")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["barfoo"]); + + let objects = query!(TestModel, $name.starts_with("foobarbaz")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_istarts_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["foobar", "Foobar", "barfoo"]).await; + + let mut objects = query!(TestModel, $name.istarts_with("FOO")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["foobar", "Foobar"]); +} + +#[cot_macros::dbtest] +async fn model_query_ends_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["report.pdf", "report.PDF", "archive.zip", "pdf"]).await; + + let objects = query!(TestModel, $name.ends_with(".pdf")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["report.pdf"]); + + let objects = query!(TestModel, $name.ends_with("report.pdf.pdf")) + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn model_query_iends_with(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["report.pdf", "report.PDF", "archive.zip"]).await; + + let mut objects = query!(TestModel, $name.iends_with(".PDF")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + assert_eq!(names_of(&objects), vec!["report.pdf", "report.PDF"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_positional(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["faXo", "fooo", "fo", "faXYo", "f_o"]).await; + + let mut objects = query!(TestModel, $name.raw_like("f??o")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["faXo", "fooo"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_middle_wildcards(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed( + test_db, + &[ + "foo_bar_baz", + "foo bar baz extra", + "foobarbaz", + "bar_foo_baz", // wrong order, must not match + ], + ) + .await; + + let objects = query!(TestModel, $name.raw_like("*foo*bar*baz*")) + .all(&**test_db) + .await + .unwrap(); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["foo bar baz extra", "foo_bar_baz", "foobarbaz"]); +} + +#[cot_macros::dbtest] +async fn model_query_raw_escaped_wildcard(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["a*b", "aXb", "a?b"]).await; + + let objects = query!(TestModel, $name.raw_like("a\\*b")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["a*b"]); + + let mut objects = query!(TestModel, $name.raw_like("a?b")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["a*b", "a?b", "aXb"]); +} + +#[cot_macros::dbtest] +async fn model_query_iraw(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["README", "ReadMe", "readme", "READMEE", "REDME"]).await; + + let mut objects = query!(TestModel, $name.iraw_like("re?dme")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + assert_eq!(got, vec!["README", "ReadMe", "readme",]); +} + +#[cot_macros::dbtest] +async fn model_query_literal_wildcard_characters_in_data(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["100% off", "under_score", "a*b", "aXb"]).await; + + let objects = query!(TestModel, $name.contains("100% off")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["100% off"]); + + let objects = query!(TestModel, $name.contains("_score")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["under_score"]); + + let objects = query!(TestModel, $name.contains("a*b")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["a*b"]); +} + +#[cot_macros::dbtest] +async fn model_query_unicode_case_sensitive(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed( + test_db, + &["café", "CAFÉ", "日本語のテスト", "🎉 party time", "naïve"], + ) + .await; + + let objects = query!(TestModel, $name.contains("café")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["café"]); + + let objects = query!(TestModel, $name.starts_with("日本")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["日本語のテスト"]); + + let objects = query!(TestModel, $name.ends_with("time")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["🎉 party time"]); + + let objects = query!(TestModel, $name.raw_like("na?ve")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["naïve"]); +} + +#[cot_macros::dbtest] +async fn model_query_contains_combined_with_boolean_ops(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + seed(test_db, &["apple pie", "apple tart", "banana split"]).await; + + let objects = query!(TestModel, $name.contains("apple") && $name.contains("pie")) + .all(&**test_db) + .await + .unwrap(); + assert_eq!(names_of(&objects), vec!["apple pie"]); + let mut objects = query!(TestModel, $name.starts_with("apple") || $name.ends_with("split")) + .all(&**test_db) + .await + .unwrap(); + objects.sort_by_key(|a| a.id.unwrap()); + let mut got = names_of(&objects); + got.sort_unstable(); + + assert_eq!(got, vec!["apple pie", "apple tart", "banana split"]); +} From b89d219dddbe58f584d78f5483556684071bdd46 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 5 Jul 2026 12:16:10 +0000 Subject: [PATCH 05/24] attempt to fix case sensitive arm on mysql --- cot/src/db/impl_mysql.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index b8a3c7f7c..6de013ea4 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -48,19 +48,20 @@ impl LikeExprBuilder for DatabaseMySql { glob_pattern: &str, case_sensitivity: CaseSensitivity, ) -> Result { - let like = LikeExpr::new(to_sql_like(glob_pattern).to_lowercase()).escape(LIKE_ESCAPE_CHAR); + let sql_pattern = to_sql_like(glob_pattern); match case_sensitivity { CaseSensitivity::Sensitive => { - let expr = lhs - .binary( - sea_query::BinOper::Custom("COLLATE"), - sea_query::Expr::cust("utf8mb4_bin"), - ) - .like(like); + let expr = SimpleExpr::cust_with_exprs( + format!("? LIKE BINARY ? ESCAPE '{LIKE_ESCAPE_CHAR}'",), + [lhs, SimpleExpr::val(sql_pattern)], + ); Ok(expr) } - CaseSensitivity::Insensitive => Ok(sea_query::Func::lower(lhs).like(like)), + CaseSensitivity::Insensitive => { + let like = LikeExpr::new(sql_pattern.to_lowercase()).escape(LIKE_ESCAPE_CHAR); + Ok(sea_query::Func::lower(lhs).like(like)) + } } } } From 9b21b01106151d2122351dfcdb5dabbfa47b4889 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 5 Jul 2026 12:23:52 +0000 Subject: [PATCH 06/24] see if this fixes the query --- cot/src/db/impl_mysql.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 6de013ea4..7a4335205 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -53,7 +53,7 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { let expr = SimpleExpr::cust_with_exprs( - format!("? LIKE BINARY ? ESCAPE '{LIKE_ESCAPE_CHAR}'",), + format!("? LIKE BINARY ? ESCAPE {LIKE_ESCAPE_CHAR}"), [lhs, SimpleExpr::val(sql_pattern)], ); Ok(expr) From 6c724cefe5e7e28bf5c4e5d0442fb0e803e8a23f Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 5 Jul 2026 18:09:50 +0000 Subject: [PATCH 07/24] fix mysql syntax error for case sensitive arm --- cot/src/db/impl_mysql.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 7a4335205..742de1950 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -53,7 +53,12 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { let expr = SimpleExpr::cust_with_exprs( - format!("? LIKE BINARY ? ESCAPE {LIKE_ESCAPE_CHAR}"), + // Doubled the escape character. MySQL's string-literal lexer needs `\\` for + // one literal `\`. We cast the pattern (not the column) so + // the column stays bare and usable by an index; + // sea_query's `LikeExpr` can't cast its pattern, so this needs raw text + // instead of `.like(...)`. + format!("? LIKE BINARY ? ESCAPE '{LIKE_ESCAPE_CHAR}{LIKE_ESCAPE_CHAR}'"), [lhs, SimpleExpr::val(sql_pattern)], ); Ok(expr) From b7b48fe5ea442c4fdd828261dfedabd28da0d199 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 5 Jul 2026 19:58:46 +0000 Subject: [PATCH 08/24] fix postgres error and some more improvements! --- cot/src/db/impl_mysql.rs | 13 ++++--- cot/src/db/impl_postgres.rs | 3 +- cot/src/db/query/expr/like.rs | 67 +++++++++++++++++------------------ 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 742de1950..b2cbb1ca2 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -53,13 +53,12 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { let expr = SimpleExpr::cust_with_exprs( - // Doubled the escape character. MySQL's string-literal lexer needs `\\` for - // one literal `\`. We cast the pattern (not the column) so - // the column stays bare and usable by an index; - // sea_query's `LikeExpr` can't cast its pattern, so this needs raw text - // instead of `.like(...)`. - format!("? LIKE BINARY ? ESCAPE '{LIKE_ESCAPE_CHAR}{LIKE_ESCAPE_CHAR}'"), - [lhs, SimpleExpr::val(sql_pattern)], + "? LIKE BINARY ? ESCAPE ?", + [ + lhs, + SimpleExpr::val(sql_pattern), + SimpleExpr::val(LIKE_ESCAPE_CHAR.to_string()), + ], ); Ok(expr) } diff --git a/cot/src/db/impl_postgres.rs b/cot/src/db/impl_postgres.rs index 1049f89eb..d255655bf 100644 --- a/cot/src/db/impl_postgres.rs +++ b/cot/src/db/impl_postgres.rs @@ -1,7 +1,6 @@ //! Database interface implementation – PostgreSQL backend. use cot::db::query::QueryBuildingError; -use cot::db::query::expr::like::LIKE_ESCAPE_CHAR; use sea_query::extension::postgres::PgExpr; use sea_query::{ExprTrait, LikeExpr, SimpleExpr}; @@ -56,7 +55,7 @@ impl LikeExprBuilder for DatabasePostgres { glob_pattern: &str, case_sensitivity: CaseSensitivity, ) -> Result { - let glob = LikeExpr::new(to_sql_like(glob_pattern)).escape(LIKE_ESCAPE_CHAR); + let glob = LikeExpr::new(to_sql_like(glob_pattern)); match case_sensitivity { CaseSensitivity::Sensitive => Ok(lhs.like(glob)), diff --git a/cot/src/db/query/expr/like.rs b/cot/src/db/query/expr/like.rs index 07777b961..a55988d7e 100644 --- a/cot/src/db/query/expr/like.rs +++ b/cot/src/db/query/expr/like.rs @@ -2,7 +2,7 @@ use cot::db::ToDbFieldValue; use cot::db::query::expr::{FieldRef, SqlQueryBuilder}; -use cot::db::query::{Expr, IntoField, QueryBuildingError}; +use cot::db::query::{Expr, QueryBuildingError}; use sea_query::{ExprTrait, SimpleExpr}; pub(crate) const LIKE_ESCAPE_CHAR: char = '\\'; @@ -14,81 +14,81 @@ pub trait ExprLike { /// comparing case-sensitively. /// /// See [`Expr::contains`] for the underlying semantics. - fn contains>(self, other: V) -> Expr; + fn contains>(self, other: V) -> Expr; /// Checks if the field contains `other` as a literal substring. /// This is the case-insensitive counterpart of [`Self::contains`]. /// /// See [`Expr::contains`] for the underlying semantics. - fn icontains>(self, other: V) -> Expr; + fn icontains>(self, other: V) -> Expr; /// Checks if the field starts with `other`, comparing /// case-sensitively. /// /// See [`Expr::starts_with`] for the underlying semantics. - fn starts_with>(self, other: V) -> Expr; + fn starts_with>(self, other: V) -> Expr; /// Checks if the field starts with `other`. /// This is the case-insensitive counterpart of [`Self::starts_with`]. /// /// See [`Expr::starts_with`] for the underlying semantics. - fn istarts_with>(self, other: V) -> Expr; + fn istarts_with>(self, other: V) -> Expr; /// Checks if the field ends with `other`, comparing case-sensitively. /// /// See [`Expr::ends_with`] for the underlying semantics. - fn ends_with>(self, other: V) -> Expr; + fn ends_with>(self, other: V) -> Expr; /// Checks if the field ends with `other`. /// This is the case-insensitive counterpart of [`Self::ends_with`]. /// /// See [`Expr::ends_with`] for the underlying semantics. - fn iends_with>(self, other: V) -> Expr; + fn iends_with>(self, other: V) -> Expr; - /// Matches an expression against the raw pattern provided in `other`, + /// Matches an expression against the raw pattern provided in `other`, /// matching case-sensitively. /// /// See [`Expr::raw_like`] for the underlying semantics. - fn raw_like>(self, other: V) -> Expr; + fn raw_like>(self, other: V) -> Expr; - /// Matches an expression against the raw pattern provided in `other`. + /// Matches an expression against the raw pattern provided in `other`. /// This is the case-insensitive counterpart of [`Self::raw_like`]. /// /// See [`Expr::iraw_like`] for the underlying semantics. - fn iraw_like>(self, other: V) -> Expr; + fn iraw_like>(self, other: V) -> Expr; } impl ExprLike for FieldRef { - fn contains>(self, other: V) -> Expr { - Expr::contains(self.as_expr(), Expr::value(other.into_field())) + fn contains>(self, other: V) -> Expr { + Expr::contains(self.as_expr(), Expr::value(other.into())) } - fn icontains>(self, other: V) -> Expr { - Expr::icontains(self.as_expr(), Expr::value(other.into_field())) + fn icontains>(self, other: V) -> Expr { + Expr::icontains(self.as_expr(), Expr::value(other.into())) } - fn starts_with>(self, other: V) -> Expr { - Expr::starts_with(self.as_expr(), Expr::value(other.into_field())) + fn starts_with>(self, other: V) -> Expr { + Expr::starts_with(self.as_expr(), Expr::value(other.into())) } - fn istarts_with>(self, other: V) -> Expr { - Expr::istarts_with(self.as_expr(), Expr::value(other.into_field())) + fn istarts_with>(self, other: V) -> Expr { + Expr::istarts_with(self.as_expr(), Expr::value(other.into())) } - fn ends_with>(self, other: V) -> Expr { - Expr::ends_with(self.as_expr(), Expr::value(other.into_field())) + fn ends_with>(self, other: V) -> Expr { + Expr::ends_with(self.as_expr(), Expr::value(other.into())) } - fn iends_with>(self, other: V) -> Expr { - Expr::iends_with(self.as_expr(), Expr::value(other.into_field())) + fn iends_with>(self, other: V) -> Expr { + Expr::iends_with(self.as_expr(), Expr::value(other.into())) } - fn raw_like>(self, other: V) -> Expr { - Expr::raw_like(self.as_expr(), Expr::value(other.into_field())) + fn raw_like>(self, other: V) -> Expr { + Expr::raw_like(self.as_expr(), Expr::value(other.into())) } - fn iraw_like>(self, other: V) -> Expr { - Expr::iraw_like(self.as_expr(), Expr::value(other.into_field())) + fn iraw_like>(self, other: V) -> Expr { + Expr::iraw_like(self.as_expr(), Expr::value(other.into())) } } @@ -137,13 +137,13 @@ fn push_like_literal(out: &mut String, ch: char) { } pub(crate) fn like_expr( - sql_dialect: &dyn SqlQueryBuilder, + sql_builder: &dyn SqlQueryBuilder, lhs: &Expr, rhs: &Expr, mode: LikeMode, case_sensitivity: CaseSensitivity, ) -> Result { - let lhs_expr = lhs.as_sea_query_expr(sql_dialect)?; + let lhs_expr = lhs.as_sea_query_expr(sql_builder)?; let Expr::Value(value) = rhs else { return Ok(sea_query::Expr::val(1).eq(0)); @@ -160,7 +160,7 @@ pub(crate) fn like_expr( LikeMode::Raw => text.clone(), }; - sql_dialect.like_expr(lhs_expr, &glob_pattern, case_sensitivity) + sql_builder.like_expr(lhs_expr, &glob_pattern, case_sensitivity) } /// Whether a pattern-matching expression should consider letter case when @@ -192,10 +192,9 @@ pub trait LikeExprBuilder { /// /// # Errors /// - /// Returns [`QueryBuildingError::UnsupportedExpr`] if the backend cannot - /// express pattern matching at all, or cannot express the requested - /// [`CaseSensitivity`] variant. Backends that support this capability - /// should never need to return any other error variant from this method. + /// Returns [`QueryBuildingError`] if the backend cannot + /// express pattern matching, or cannot express the requested + /// [`CaseSensitivity`] variant. fn like_expr( &self, lhs: SimpleExpr, From 86d311d88170740e9e582ae7af5286afd253f1ad Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 5 Jul 2026 23:03:56 +0000 Subject: [PATCH 09/24] update query macro docs --- cot/src/db.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/cot/src/db.rs b/cot/src/db.rs index 3938de233..792006d65 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -419,6 +419,75 @@ pub use cot_macros::model; /// let _ = query!(Customer, $status == constants::ACTIVE_STATUS); /// let _ = query!(Customer, $id == next_customer_id()); /// ``` +/// +/// ## String pattern matching +/// +/// Query expressions support substring, prefix, suffix, and raw +/// glob-pattern matching on `String` fields, via +/// [`contains`](cot::db::query::expr::Expr::contains), +/// [`starts_with`](cot::db::query::expr::Expr::starts_with), +/// [`ends_with`](cot::db::query::expr::Expr::ends_with), and +/// [`raw_like`](cot::db::query::expr::Expr::raw_like), along with an +/// `i`-prefixed case-insensitive counterpart of each +/// ([`icontains`](cot::db::query::expr::Expr::icontains), +/// [`istarts_with`](cot::db::query::expr::Expr::istarts_with), +/// [`iends_with`](cot::db::query::expr::Expr::iends_with), +/// [`iraw_like`](cot::db::query::expr::Expr::iraw_like)). +/// +/// ``` +/// use cot::db::{model, query}; +/// +/// # #[model] +/// # struct Customer { +/// # #[model(primary_key)] +/// # id: i32, +/// # full_name: String, +/// # } +/// let _ = query!(Customer, $full_name.contains("Doe")); +/// let _ = query!(Customer, $full_name.icontains("doe")); +/// let _ = query!(Customer, $full_name.starts_with("Jon")); +/// let _ = query!(Customer, $full_name.istarts_with("jon")); +/// let _ = query!(Customer, $full_name.ends_with("Doe")); +/// let _ = query!(Customer, $full_name.iends_with("doe")); +/// ``` +/// +/// `raw_like`/`iraw_like` accept a raw glob pattern (`*` for zero-or-more +/// characters, `?` for exactly one, `\` to escape a wildcard) rather than +/// a plain literal, for shapes the other four methods can't express, such +/// as a fixed-width or middle-of-string match: +/// +/// ``` +/// use cot::db::{model, query}; +/// +/// # #[model] +/// # struct Customer { +/// # #[model(primary_key)] +/// # id: i32, +/// # full_name: String, +/// # } +/// let _ = query!(Customer, $full_name.raw_like("J?n Do*")); +/// let _ = query!(Customer, $full_name.iraw_like("j*n ??e")); +/// ``` +/// +/// These methods can be combined with boolean and comparison operators +/// like any other expression: +/// +/// ``` +/// use cot::db::{model, query}; +/// +/// # #[model] +/// # struct Customer { +/// # #[model(primary_key)] +/// # id: i32, +/// # full_name: String, +/// # is_active: bool, +/// # } +/// let _ = query!(Customer, $full_name.contains("Doe") && $is_active == true); +/// ``` +/// +/// See [`Expr::contains`](cot::db::query::expr::Expr::contains) and +/// [`Expr::raw_like`](cot::db::query::expr::Expr::raw_like) for the full +/// semantics, escaping rules, and glob pattern syntax. pub use cot_macros::query; use derive_more::{Debug, Deref, Display}; #[cfg(test)] From 4588ab9a677569765eba95dd4e2439f1c24235d7 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 7 Jul 2026 00:03:28 +0000 Subject: [PATCH 10/24] change mysql like arm for case sensitivity to use utf8 collation --- cot/src/db/impl_mysql.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index b2cbb1ca2..baff1d960 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -52,14 +52,15 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { - let expr = SimpleExpr::cust_with_exprs( - "? LIKE BINARY ? ESCAPE ?", - [ - lhs, - SimpleExpr::val(sql_pattern), - SimpleExpr::val(LIKE_ESCAPE_CHAR.to_string()), - ], - ); + let expr = lhs + .binary( + sea_query::BinOper::Custom("COLLATE"), + // We assume that the database is using utf8mb4 character set, which is the + // default in MySQL 8.0. See https://dev.mysql.com/doc/refman/9.7/en/charset.html + // TODO: Allow users to change collation in the future if needed. + sea_query::Expr::cust("utf8mb4_bin"), + ) + .like(sql_pattern); Ok(expr) } CaseSensitivity::Insensitive => { From f0dde66b2c538f8efba72ca84717bf3903f62580 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 7 Jul 2026 00:38:25 +0000 Subject: [PATCH 11/24] fix the mysql issue for real --- cot/src/db/impl_mysql.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index baff1d960..1220cfa29 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -52,16 +52,12 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { - let expr = lhs - .binary( - sea_query::BinOper::Custom("COLLATE"), - // We assume that the database is using utf8mb4 character set, which is the - // default in MySQL 8.0. See https://dev.mysql.com/doc/refman/9.7/en/charset.html - // TODO: Allow users to change collation in the future if needed. - sea_query::Expr::cust("utf8mb4_bin"), - ) - .like(sql_pattern); - Ok(expr) + // We assume that the database is using utf8mb4 character set, which is the + // default in MySQL 8.0. See https://dev.mysql.com/doc/refman/9.7/en/charset.html + // TODO: Allow users to change collation in the future if needed. + let collated_lhs = sea_query::Expr::cust_with_exprs("? COLLATE utf8mb4_bin", [lhs]); + let like = LikeExpr::new(sql_pattern).escape(LIKE_ESCAPE_CHAR); + Ok(collated_lhs.like(like)) } CaseSensitivity::Insensitive => { let like = LikeExpr::new(sql_pattern.to_lowercase()).escape(LIKE_ESCAPE_CHAR); From 28159146cff8fed6a934c6cd451b55dae7346489 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 13 Jul 2026 17:07:40 +0000 Subject: [PATCH 12/24] move to_sqlite_glob out of DatabaseSqlite --- cot/src/db/impl_sqlite.rs | 60 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 136f6ff51..516b50301 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -42,36 +42,6 @@ impl DatabaseSqlite { } } -impl DatabaseSqlite { - fn convert_to_sqlite_glob(glob: &str) -> String { - let mut escaped = String::with_capacity(glob.len()); - let mut chars = glob.chars(); - while let Some(ch) = chars.next() { - match ch { - LIKE_ESCAPE_CHAR => { - if let Some(ch) = chars.next() { - Self::push_glob_literal(&mut escaped, ch); - } - } - '*' => escaped.push('*'), - '?' => escaped.push('?'), - other => Self::push_glob_literal(&mut escaped, other), - } - } - escaped - } - - fn push_glob_literal(out: &mut String, ch: char) { - if matches!(ch, '*' | '?' | '[') { - out.push('['); - out.push(ch); - out.push(']'); - } else { - out.push(ch); - } - } -} - impl LikeExprBuilder for DatabaseSqlite { fn like_expr( &self, @@ -81,7 +51,7 @@ impl LikeExprBuilder for DatabaseSqlite { ) -> Result { match case_sensitivity { CaseSensitivity::Sensitive => { - let glob = Self::convert_to_sqlite_glob(glob_pattern); + let glob = to_sqlite_glob(glob_pattern); Ok(lhs.glob(glob)) } CaseSensitivity::Insensitive => { @@ -92,3 +62,31 @@ impl LikeExprBuilder for DatabaseSqlite { } } } + +fn to_sqlite_glob(glob: &str) -> String { + let mut escaped = String::with_capacity(glob.len()); + let mut chars = glob.chars(); + while let Some(ch) = chars.next() { + match ch { + LIKE_ESCAPE_CHAR => { + if let Some(ch) = chars.next() { + push_glob_literal(&mut escaped, ch); + } + } + '*' => escaped.push('*'), + '?' => escaped.push('?'), + other => push_glob_literal(&mut escaped, other), + } + } + escaped +} + +fn push_glob_literal(out: &mut String, ch: char) { + if matches!(ch, '*' | '?' | '[') { + out.push('['); + out.push(ch); + out.push(']'); + } else { + out.push(ch); + } +} From 7a7f055445e15f534beb3fdc2b4b776cdddbc78b Mon Sep 17 00:00:00 2001 From: Elijah Date: Wed, 15 Jul 2026 01:21:09 +0000 Subject: [PATCH 13/24] add some more tests --- cot-macros/src/query.rs | 93 +++++++++++++++++-- cot-macros/tests/compile_tests.rs | 2 + ...func_query_field_ref_method_wrong_arity.rs | 13 +++ ..._query_field_ref_method_wrong_arity.stderr | 13 +++ ...unc_query_field_ref_non_existing_method.rs | 12 +++ ...query_field_ref_non_existing_method.stderr | 5 + 6 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.rs create mode 100644 cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr create mode 100644 cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs create mode 100644 cot-macros/tests/ui/func_query_field_ref_non_existing_method.stderr diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index eb549a54c..ed83a328f 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -23,7 +23,7 @@ impl Parse for Query { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)] pub(crate) enum StringMethod { Contains { case_sensitive: bool }, StartsWith { case_sensitive: bool }, @@ -254,11 +254,6 @@ fn handle_string_method( }; } - // If the receiver isn't a bare field reference (e.g. a composite - // expression like `$a + $b`), fall back to the untyped `Expr` constructor. - // This bypasses compile-time type checking, so a mismatched type will - // only surface as a runtime error (or, in the worst case, a - // silently-wrong query) rather than a compile error. let receiver_tokens = expr_to_tokens(model_name, receiver); quote! { #crate_name::db::query::expr::Expr::#method_ident( @@ -267,3 +262,89 @@ fn handle_string_method( ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_string_method_all_names() { + let all_names = StringMethod::all_names(); + assert_eq!(all_names.len(), 8); + assert_eq!( + all_names, + [ + "contains", + "icontains", + "starts_with", + "istarts_with", + "ends_with", + "iends_with", + "raw_like", + "iraw_like", + ] + ); + } + + #[test] + fn test_string_method_from_ident() { + let idents = [ + ( + "contains", + Some(StringMethod::Contains { + case_sensitive: true, + }), + ), + ( + "icontains", + Some(StringMethod::Contains { + case_sensitive: false, + }), + ), + ( + "starts_with", + Some(StringMethod::StartsWith { + case_sensitive: true, + }), + ), + ( + "istarts_with", + Some(StringMethod::StartsWith { + case_sensitive: false, + }), + ), + ( + "ends_with", + Some(StringMethod::EndsWith { + case_sensitive: true, + }), + ), + ( + "iends_with", + Some(StringMethod::EndsWith { + case_sensitive: false, + }), + ), + ( + "raw_like", + Some(StringMethod::Raw { + case_sensitive: true, + }), + ), + ( + "iraw_like", + Some(StringMethod::Raw { + case_sensitive: false, + }), + ), + ("__non_existent__", None), + ]; + + for (ident, expected) in idents { + assert_eq!( + StringMethod::from_ident(&format_ident!("{}", ident)), + expected + ); + } + } +} diff --git a/cot-macros/tests/compile_tests.rs b/cot-macros/tests/compile_tests.rs index 0ddab5d6b..96fb3ebd6 100644 --- a/cot-macros/tests/compile_tests.rs +++ b/cot-macros/tests/compile_tests.rs @@ -64,6 +64,8 @@ fn func_query() { t.compile_fail("tests/ui/func_query_double_field.rs"); t.compile_fail("tests/ui/func_query_invalid_field.rs"); t.compile_fail("tests/ui/func_query_method_call_on_db_field.rs"); + t.compile_fail("tests/ui/func_query_field_ref_method_wrong_arity.rs"); + t.compile_fail("tests/ui/func_query_field_ref_non_existing_method.rs"); } #[rustversion::attr( diff --git a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.rs b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.rs new file mode 100644 index 000000000..1b7fdc8ad --- /dev/null +++ b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.rs @@ -0,0 +1,13 @@ +use cot::db::{model, query}; + +#[model] +struct MyModel { + #[model(primary_key)] + id: i32, + name: String, +} + +fn main() { + query!(MyModel, $name.contains()); + query!(MyModel, $name.contains("a", "b")); +} diff --git a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr new file mode 100644 index 000000000..d133e80f3 --- /dev/null +++ b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr @@ -0,0 +1,13 @@ +error: `contains` expects exactly one string argument + --> tests/ui/func_query_field_ref_method_wrong_arity.rs:11:5 + | +11 | query!(MyModel, $name.contains()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `contains` expects exactly one string argument + --> tests/ui/func_query_field_ref_method_wrong_arity.rs:12:36 + | +12 | query!(MyModel, $name.contains("a", "b")); + | ^^^ diff --git a/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs new file mode 100644 index 000000000..47256a9ae --- /dev/null +++ b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs @@ -0,0 +1,12 @@ +use cot::db::{model, query}; + +#[model] +struct MyModel { + #[model(primary_key)] + id: i32, + name: String, +} + +fn main() { + query!(MyModel, $name.non_existing_method()); +} \ No newline at end of file diff --git a/cot-macros/tests/ui/func_query_field_ref_non_existing_method.stderr b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.stderr new file mode 100644 index 000000000..4e48c4ca9 --- /dev/null +++ b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.stderr @@ -0,0 +1,5 @@ +error: calling functions that reference database fields is unsupported (only contains, icontains, starts_with, istarts_with, ends_with, iends_with, raw_like, iraw_like are supported directly on database fields) + --> tests/ui/func_query_field_ref_non_existing_method.rs:11:21 + | +11 | query!(MyModel, $name.non_existing_method()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ From 4dbd04b67e7db7748debb21c935efac261356a3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:21:24 +0000 Subject: [PATCH 14/24] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs index 47256a9ae..32734be54 100644 --- a/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs +++ b/cot-macros/tests/ui/func_query_field_ref_non_existing_method.rs @@ -9,4 +9,4 @@ struct MyModel { fn main() { query!(MyModel, $name.non_existing_method()); -} \ No newline at end of file +} From 1141ce210989817bffa87e4234a4c7a87b590b96 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 21 Jul 2026 15:41:42 +0000 Subject: [PATCH 15/24] small refactor and allow fieldref methods with variable arity --- cot-macros/src/query.rs | 212 ++++++++---------- ..._query_field_ref_method_wrong_arity.stderr | 4 +- 2 files changed, 101 insertions(+), 115 deletions(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index ed83a328f..1228f44d5 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -24,85 +24,58 @@ impl Parse for Query { } #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, PartialOrd, Ord)] -pub(crate) enum StringMethod { - Contains { case_sensitive: bool }, - StartsWith { case_sensitive: bool }, - EndsWith { case_sensitive: bool }, - Raw { case_sensitive: bool }, +pub(crate) struct FieldRefMethod { + name: &'static str, + arity: u32, } -impl StringMethod { - pub(crate) fn from_ident(ident: &syn::Ident) -> Option { - match ident.to_string().as_str() { - "contains" => Some(Self::Contains { - case_sensitive: true, - }), - "icontains" => Some(Self::Contains { - case_sensitive: false, - }), - "starts_with" => Some(Self::StartsWith { - case_sensitive: true, - }), - "istarts_with" => Some(Self::StartsWith { - case_sensitive: false, - }), - "ends_with" => Some(Self::EndsWith { - case_sensitive: true, - }), - "iends_with" => Some(Self::EndsWith { - case_sensitive: false, - }), - "raw_like" => Some(Self::Raw { - case_sensitive: true, - }), - "iraw_like" => Some(Self::Raw { - case_sensitive: false, - }), - _ => None, - } +const FIELD_REF_METHODS: &[FieldRefMethod] = &[ + FieldRefMethod { + name: "contains", + arity: 1, + }, + FieldRefMethod { + name: "icontains", + arity: 1, + }, + FieldRefMethod { + name: "starts_with", + arity: 1, + }, + FieldRefMethod { + name: "istarts_with", + arity: 1, + }, + FieldRefMethod { + name: "ends_with", + arity: 1, + }, + FieldRefMethod { + name: "iends_with", + arity: 1, + }, + FieldRefMethod { + name: "raw_like", + arity: 1, + }, + FieldRefMethod { + name: "iraw_like", + arity: 1, + }, +]; + +impl FieldRefMethod { + pub(crate) fn lookup(ident: &syn::Ident) -> Option<&'static Self> { + let name = ident.to_string(); + FIELD_REF_METHODS.iter().find(|m| m.name == name) } pub(crate) fn as_ident(self) -> syn::Ident { - let name = match self { - Self::Contains { - case_sensitive: true, - } => "contains", - Self::Contains { - case_sensitive: false, - } => "icontains", - Self::StartsWith { - case_sensitive: true, - } => "starts_with", - Self::StartsWith { - case_sensitive: false, - } => "istarts_with", - Self::EndsWith { - case_sensitive: true, - } => "ends_with", - Self::EndsWith { - case_sensitive: false, - } => "iends_with", - Self::Raw { - case_sensitive: true, - } => "raw_like", - Self::Raw { - case_sensitive: false, - } => "iraw_like", - }; - format_ident!("{name}") + format_ident!("{}", self.name) } - pub(crate) fn all_names() -> &'static [&'static str] { - &[ - "contains", - "icontains", - "starts_with", - "istarts_with", - "ends_with", - "iends_with", - "raw_like", - "iraw_like", - ] + pub(crate) fn all_names() -> impl Iterator { + FIELD_REF_METHODS.iter().map(|m| m.name) } } @@ -158,18 +131,18 @@ pub(super) fn expr_to_tokens(model_name: &syn::Type, expr: Expr) -> TokenStream if non_field_tokens.is_none() && let Expr::MemberAccess { member_name, .. } = &*function - && let Some(method) = StringMethod::from_ident(member_name) + && let Some(method) = FieldRefMethod::lookup(member_name) { let Expr::MemberAccess { parent, .. } = *function else { unreachable!("function call must have a parent"); }; - return handle_string_method(model_name, *parent, args, method); + return handle_field_ref_method(model_name, *parent, &args, *method); } if let Some(tokens) = non_field_tokens { quote!(#crate_name::db::query::expr::Expr::value(#tokens(#(#args),*))) } else { - let all_function_names = StringMethod::all_names().join(", "); + let all_function_names = FieldRefMethod::all_names().collect::>().join(", "); let msg = format!( "calling functions that reference database fields is unsupported \ (only {all_function_names} are supported directly on database fields)" @@ -222,43 +195,48 @@ fn handle_binary_comparison( quote!(#crate_name::db::query::expr::Expr::#bin_fn(#lhs, #rhs)) } -fn handle_string_method( +fn handle_field_ref_method( model_name: &syn::Type, receiver: Expr, - args: Vec, - method: StringMethod, + args: &[syn::Expr], + method: FieldRefMethod, ) -> TokenStream { let crate_name = cot_ident(); let method_ident = method.as_ident(); - let arg = match <[syn::Expr; 1]>::try_from(args) { - Ok([arg]) => arg, - Err(args) => { - let span = args - .first() - .map_or_else(proc_macro2::Span::call_site, syn::spanned::Spanned::span); - return syn::Error::new( - span, - format!("`{method_ident}` expects exactly one string argument"), - ) - .to_compile_error(); - } - }; + if method.arity as usize != args.len() { + let arity = method.arity; + let span = args + .first() + .map_or_else(proc_macro2::Span::call_site, syn::spanned::Spanned::span); + return syn::Error::new( + span, + format!( + "`{method_ident}` expects {arity} arguments, found {}", + args.len() + ), + ) + .to_compile_error(); + } if let Expr::FieldRef { ref field_name, .. } = receiver { return quote! { #crate_name::db::query::expr::ExprLike::#method_ident( <#model_name as #crate_name::db::Model>::Fields::#field_name, - #arg + #(#args),* ) }; } let receiver_tokens = expr_to_tokens(model_name, receiver); + let wrapped_args = args + .iter() + .map(|arg| quote!(#crate_name::db::query::expr::Expr::value(#arg))); + quote! { #crate_name::db::query::expr::Expr::#method_ident( #receiver_tokens, - #crate_name::db::query::expr::Expr::value(#arg) + #(#wrapped_args),* ) } } @@ -268,8 +246,8 @@ mod tests { use super::*; #[test] - fn test_string_method_all_names() { - let all_names = StringMethod::all_names(); + fn test_field_ref_method_all_names() { + let all_names = FieldRefMethod::all_names().collect::>(); assert_eq!(all_names.len(), 8); assert_eq!( all_names, @@ -287,54 +265,62 @@ mod tests { } #[test] - fn test_string_method_from_ident() { + fn test_field_ref_method_lookup() { let idents = [ ( "contains", - Some(StringMethod::Contains { - case_sensitive: true, + Some(FieldRefMethod { + name: "contains", + arity: 1, }), ), ( "icontains", - Some(StringMethod::Contains { - case_sensitive: false, + Some(FieldRefMethod { + name: "icontains", + arity: 1, }), ), ( "starts_with", - Some(StringMethod::StartsWith { - case_sensitive: true, + Some(FieldRefMethod { + name: "starts_with", + arity: 1, }), ), ( "istarts_with", - Some(StringMethod::StartsWith { - case_sensitive: false, + Some(FieldRefMethod { + name: "istarts_with", + arity: 1, }), ), ( "ends_with", - Some(StringMethod::EndsWith { - case_sensitive: true, + Some(FieldRefMethod { + name: "ends_with", + arity: 1, }), ), ( "iends_with", - Some(StringMethod::EndsWith { - case_sensitive: false, + Some(FieldRefMethod { + name: "iends_with", + arity: 1, }), ), ( "raw_like", - Some(StringMethod::Raw { - case_sensitive: true, + Some(FieldRefMethod { + name: "raw_like", + arity: 1, }), ), ( "iraw_like", - Some(StringMethod::Raw { - case_sensitive: false, + Some(FieldRefMethod { + name: "iraw_like", + arity: 1, }), ), ("__non_existent__", None), @@ -342,7 +328,7 @@ mod tests { for (ident, expected) in idents { assert_eq!( - StringMethod::from_ident(&format_ident!("{}", ident)), + FieldRefMethod::lookup(&format_ident!("{}", ident)).copied(), expected ); } diff --git a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr index d133e80f3..02c2ad35b 100644 --- a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr +++ b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr @@ -1,4 +1,4 @@ -error: `contains` expects exactly one string argument +error: `contains` expects exactly 1 argument found 0 --> tests/ui/func_query_field_ref_method_wrong_arity.rs:11:5 | 11 | query!(MyModel, $name.contains()); @@ -6,7 +6,7 @@ error: `contains` expects exactly one string argument | = note: this error originates in the macro `query` (in Nightly builds, run with -Z macro-backtrace for more info) -error: `contains` expects exactly one string argument +error: `contains` expects exactly 1 argument found 2 --> tests/ui/func_query_field_ref_method_wrong_arity.rs:12:36 | 12 | query!(MyModel, $name.contains("a", "b")); From 8f1861e23fafb76d06b6a4fe41f533332b7ecfc6 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 24 Jul 2026 01:05:09 +0000 Subject: [PATCH 16/24] add unit tests --- cot/src/db/impl_mysql.rs | 88 +++++++++++++++++++++++++++- cot/src/db/impl_postgres.rs | 76 +++++++++++++++++++++++++ cot/src/db/impl_sqlite.rs | 104 ++++++++++++++++++++++++++++++++++ cot/src/db/query/expr/like.rs | 23 ++++++++ cot/src/test.rs | 9 ++- 5 files changed, 296 insertions(+), 4 deletions(-) diff --git a/cot/src/db/impl_mysql.rs b/cot/src/db/impl_mysql.rs index 1220cfa29..eaf2c314b 100644 --- a/cot/src/db/impl_mysql.rs +++ b/cot/src/db/impl_mysql.rs @@ -53,7 +53,7 @@ impl LikeExprBuilder for DatabaseMySql { match case_sensitivity { CaseSensitivity::Sensitive => { // We assume that the database is using utf8mb4 character set, which is the - // default in MySQL 8.0. See https://dev.mysql.com/doc/refman/9.7/en/charset.html + // default in MySQL 8.0. See https://dev.mysql.com/doc/refman/8.0/en/charset.html // TODO: Allow users to change collation in the future if needed. let collated_lhs = sea_query::Expr::cust_with_exprs("? COLLATE utf8mb4_bin", [lhs]); let like = LikeExpr::new(sql_pattern).escape(LIKE_ESCAPE_CHAR); @@ -66,3 +66,89 @@ impl LikeExprBuilder for DatabaseMySql { } } } + +#[cfg(test)] +mod tests { + use sea_query::{Alias, Asterisk, MysqlQueryBuilder, Query}; + + use super::*; + use crate::test::DEFAULT_MYSQL_TEST_URL; + + #[expect(clippy::unused_async)] + async fn test_db() -> DatabaseMySql { + let db_url = + std::env::var("MYSQL_URL").unwrap_or_else(|_| DEFAULT_MYSQL_TEST_URL.to_string()); + let db_connection = sqlx::mysql::MySqlPoolOptions::new() + .connect_lazy(&format!("{db_url}/mysql")) + .expect("lazy pool creation should not fail"); + DatabaseMySql { db_connection } + } + + fn col_expr() -> SimpleExpr { + sea_query::Expr::col(Alias::new("name")) + } + + fn render(expr: SimpleExpr) -> String { + Query::select() + .column(Asterisk) + .from(Alias::new("t")) + .and_where(expr) + .to_string(MysqlQueryBuilder) + } + + fn assert_where(expr: SimpleExpr, expected_where: &str) { + let sql = render(expr); + let expected = format!("SELECT * FROM `t` WHERE {expected_where}"); + assert_eq!(sql, expected); + } + + #[ignore = "Tests that use MySQL are ignored by default"] + #[cot::test] + async fn case_sensitive_applies_binary_collation() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "foo%bar", CaseSensitivity::Sensitive) + .unwrap(); + assert_where( + expr, + "(`name` COLLATE utf8mb4_bin) LIKE 'foo\\\\%bar' ESCAPE '\\\\'", + ); + } + + #[ignore = "Tests that use MySQL are ignored by default"] + #[cot::test] + async fn case_sensitive_translates_positional_wildcards() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "f??o", CaseSensitivity::Sensitive) + .unwrap(); + assert_where( + expr, + "(`name` COLLATE utf8mb4_bin) LIKE 'f__o' ESCAPE '\\\\'", + ); + } + + #[ignore = "Tests that use MySQL are ignored by default"] + #[cot::test] + async fn case_insensitive_lowercases_column_and_pattern() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "FOO*", CaseSensitivity::Insensitive) + .unwrap(); + assert_where(expr, "LOWER(`name`) LIKE 'foo%' ESCAPE '\\\\'"); + } + + #[ignore = "Tests that use MySQL are ignored by default"] + #[cot::test] + async fn case_insensitive_does_not_force_binary_collation() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "foo*", CaseSensitivity::Insensitive) + .unwrap(); + let sql = render(expr); + assert!( + !sql.contains("COLLATE"), + "insensitive path relies on LOWER, not collation: {sql}" + ); + } +} diff --git a/cot/src/db/impl_postgres.rs b/cot/src/db/impl_postgres.rs index d255655bf..46d3f9b06 100644 --- a/cot/src/db/impl_postgres.rs +++ b/cot/src/db/impl_postgres.rs @@ -63,3 +63,79 @@ impl LikeExprBuilder for DatabasePostgres { } } } + +#[cfg(test)] +mod tests { + use sea_query::{Alias, Asterisk, PostgresQueryBuilder, Query}; + + use super::*; + use crate::test::DEFAULT_POSTGRES_TEST_URL; + + #[expect(clippy::unused_async)] + async fn test_db() -> DatabasePostgres { + let db_url = + std::env::var("POSTGRES_URL").unwrap_or_else(|_| DEFAULT_POSTGRES_TEST_URL.to_string()); + let db_connection = sqlx::postgres::PgPoolOptions::new() + .connect_lazy(&format!("{db_url}/postgres")) + .expect("lazy pool creation should not fail"); + DatabasePostgres { db_connection } + } + + fn col_expr() -> SimpleExpr { + sea_query::Expr::col(Alias::new("name")) + } + + fn render(expr: SimpleExpr) -> String { + Query::select() + .column(Asterisk) + .from(Alias::new("t")) + .and_where(expr) + .to_string(PostgresQueryBuilder) + } + + fn assert_where(expr: SimpleExpr, expected_where: &str) { + let sql = render(expr); + let expected = format!("SELECT * FROM \"t\" WHERE {expected_where}"); + assert_eq!(sql, expected); + } + + #[ignore = "Tests that use PostgreSQL are ignored by default"] + #[cot::test] + async fn case_sensitive_uses_like_not_ilike() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "foo*", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" LIKE 'foo%'"); + } + + #[ignore = "Tests that use PostgreSQL are ignored by default"] + #[cot::test] + async fn case_insensitive_uses_ilike_and_preserves_pattern_case() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "Foo*", CaseSensitivity::Insensitive) + .unwrap(); + assert_where(expr, "\"name\" ILIKE 'Foo%'"); + } + + #[ignore = "Tests that use PostgreSQL are ignored by default"] + #[cot::test] + async fn glob_wildcards_translate_to_sql_wildcards() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "f??o", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" LIKE 'f__o'"); + } + + #[ignore = "Tests that use PostgreSQL are ignored by default"] + #[cot::test] + async fn literal_percent_and_underscore_are_escaped() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "100%off_sale", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" LIKE E'100\\\\%off\\\\_sale'"); + } +} diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 516b50301..484c19ebc 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -90,3 +90,107 @@ fn push_glob_literal(out: &mut String, ch: char) { out.push(ch); } } + +#[cfg(test)] +mod tests { + use sea_query::{Alias, Asterisk, Query, SqliteQueryBuilder}; + + use super::*; + use crate::test::TestDatabase; + + #[test] + fn test_to_sqlite_glob() { + assert_eq!(to_sqlite_glob("*foo*"), "*foo*"); + assert_eq!(to_sqlite_glob("f?o"), "f?o"); + assert_eq!(to_sqlite_glob("a\\*b"), "a[*]b"); + assert_eq!(to_sqlite_glob("a\\?b"), "a[?]b"); + assert_eq!(to_sqlite_glob("100[percent]"), "100[[]percent]"); + assert_eq!(to_sqlite_glob("[abc]"), "[[]abc]"); + assert_eq!(to_sqlite_glob("hello world"), "hello world"); + assert_eq!(to_sqlite_glob(""), ""); + assert_eq!(to_sqlite_glob("foo\\"), "foo"); + assert_eq!(to_sqlite_glob("C:\\\\Users"), "C:\\Users"); + assert_eq!(to_sqlite_glob("*foo\\*bar?baz*"), "*foo[*]bar?baz*"); + assert_eq!(to_sqlite_glob("\\*\\?\\["), "[*][?][[]"); + assert_eq!(to_sqlite_glob("café*"), "café*"); + assert_eq!(to_sqlite_glob("日本語?"), "日本語?"); + assert_eq!(to_sqlite_glob("***"), "***"); + assert_eq!(to_sqlite_glob("???"), "???"); + } + + fn assert_where(expr: SimpleExpr, expected_where: &str) { + let sql = render(expr); + let expected = format!("SELECT * FROM \"t\" WHERE {expected_where}"); + assert_eq!(sql, expected); + } + + async fn test_db() -> TestDatabase { + TestDatabase::new_sqlite().await.unwrap() + } + + fn col_expr() -> SimpleExpr { + sea_query::Expr::col(Alias::new("name")) + } + + fn render(expr: SimpleExpr) -> String { + Query::select() + .column(Asterisk) + .from(Alias::new("t")) + .and_where(expr) + .to_string(SqliteQueryBuilder) + } + + #[cot::test] + async fn case_sensitive_uses_glob() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "foo*", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" GLOB 'foo*'"); + } + + #[cot::test] + async fn case_sensitive_positional_pattern_translates_question_marks() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "f??o", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" GLOB 'f??o'"); + } + + #[cot::test] + async fn case_sensitive_escapes_literal_wildcard_chars_for_glob() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "50\\*off", CaseSensitivity::Sensitive) + .unwrap(); + assert_where(expr, "\"name\" GLOB '50[*]off'"); + } + + #[cot::test] + async fn case_insensitive_uses_lower_and_like() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "Foo*", CaseSensitivity::Insensitive) + .unwrap(); + assert_where(expr, "LOWER(\"name\") LIKE 'foo%' ESCAPE '\\'"); + } + + #[cot::test] + async fn case_insensitive_pattern_is_lowercased_before_conversion() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "README", CaseSensitivity::Insensitive) + .unwrap(); + assert_where(expr, "LOWER(\"name\") LIKE 'readme' ESCAPE '\\'"); + } + + #[cot::test] + async fn case_insensitive_includes_escape_clause() { + let db = test_db().await; + let expr = db + .like_expr(col_expr(), "100\\%off", CaseSensitivity::Insensitive) + .unwrap(); + assert_where(expr, "LOWER(\"name\") LIKE '100\\%off' ESCAPE '\\'"); + } +} diff --git a/cot/src/db/query/expr/like.rs b/cot/src/db/query/expr/like.rs index a55988d7e..089d41cd7 100644 --- a/cot/src/db/query/expr/like.rs +++ b/cot/src/db/query/expr/like.rs @@ -202,3 +202,26 @@ pub trait LikeExprBuilder { case_sensitivity: CaseSensitivity, ) -> Result; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_glob_wildcards_to_sql_wildcards() { + assert_eq!(to_sql_like("*foo*"), "%foo%"); + assert_eq!(to_sql_like("foo*"), "foo%"); + assert_eq!(to_sql_like("*foo"), "%foo"); + assert_eq!(to_sql_like("f?o"), "f_o"); + assert_eq!(to_sql_like("f??o"), "f__o"); + assert_eq!(to_sql_like("100% off"), "100\\% off"); + assert_eq!(to_sql_like("under_score"), "under\\_score"); + assert_eq!(to_sql_like("a\\*b"), "a*b"); + assert_eq!(to_sql_like("a\\?b"), "a?b"); + assert_eq!(to_sql_like("C:\\\\Users"), "C:\\\\Users"); + assert_eq!(to_sql_like(""), ""); + assert_eq!(to_sql_like("foo\\"), "foo"); + assert_eq!(to_sql_like("café*"), "café%"); + assert_eq!(to_sql_like("日本語"), "日本語"); + } +} diff --git a/cot/src/test.rs b/cot/src/test.rs index b123cc191..24c2d61fb 100644 --- a/cot/src/test.rs +++ b/cot/src/test.rs @@ -51,6 +51,9 @@ use crate::session::Session; use crate::static_files::{StaticFile, StaticFiles}; use crate::{Body, Bootstrapper, Project, ProjectContext, Result}; +pub(crate) const DEFAULT_POSTGRES_TEST_URL: &str = "postgresql://cot:cot@localhost"; +pub(crate) const DEFAULT_MYSQL_TEST_URL: &str = "mysql://root:@localhost"; + /// A test client for making requests to a Cot project. /// /// This client is useful for end-to-end testing of Cot projects. @@ -987,8 +990,8 @@ impl TestDatabase { /// # } /// ``` pub async fn new_postgres(test_name: &str) -> Result { - let db_url = std::env::var("POSTGRES_URL") - .unwrap_or_else(|_| "postgresql://cot:cot@localhost".to_string()); + let db_url = + std::env::var("POSTGRES_URL").unwrap_or_else(|_| DEFAULT_POSTGRES_TEST_URL.to_string()); let database = Database::new(format!("{db_url}/postgres")).await?; let test_database_name = format!("test_cot__{test_name}"); @@ -1056,7 +1059,7 @@ impl TestDatabase { /// ``` pub async fn new_mysql(test_name: &str) -> Result { let db_url = - std::env::var("MYSQL_URL").unwrap_or_else(|_| "mysql://root:@localhost".to_string()); + std::env::var("MYSQL_URL").unwrap_or_else(|_| DEFAULT_MYSQL_TEST_URL.to_string()); let database = Database::new(format!("{db_url}/mysql")).await?; let test_database_name = format!("test_cot__{test_name}"); From 1bc09e84534e510ecb396dc70300041e010be012 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 24 Jul 2026 02:25:35 +0000 Subject: [PATCH 17/24] update docs --- docs/databases/queries.md | 106 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/docs/databases/queries.md b/docs/databases/queries.md index b9cd43f02..61e4db3d4 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -314,6 +314,112 @@ The example above shows how to retrieve all customers with a primary key greater Similarly, the [`query`](macro@cot::db::query) macro returns a new [`Query`](struct@cot::db::query::Query) instance which can be used to chain multiple filters. +### Searching within a field (pattern matching) + +Sometimes an exact match isn't what you want. You might want to find all customers whose name contains a certain word, or all products whose name starts with a certain prefix. Cot supports this kind of substring, prefix, and suffix search directly in the `query!` macro (and, if you're building expressions by hand, on [`Expr`](enum@cot::db::query::expr::Expr) as well). + +```rust +use cot::db::Database; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn search_customers(db: Database) -> cot::Result<()> { + let customers = query!(Customer, $full_name.contains("Doe")).all(&db).await?; + println!("Customers: {:?}", customers); +# Ok(()) +} +``` + +Alongside `contains`, there's `starts_with` and `ends_with`, which anchor the match to the beginning or the end of the field's value instead of allowing it anywhere: + +```rust +use cot::db::Database; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn search_customers_further(db: Database) -> cot::Result<()> { + // Matches "Jon Doe", "Jonathan Smith", etc. + let jons = query!(Customer, $full_name.starts_with("Jon")).all(&db).await?; + + // Matches anyone whose name ends with "Doe" + let does = query!(Customer, $full_name.ends_with("Doe")).all(&db).await?; + + println!("{:?} {:?}", jons, does); +# Ok(()) +} +``` + +By default, all three of these are case-sensitive, so `contains("Doe")` won't match a customer named "jane doe". If you'd rather the match ignore case, each method has an `i`-prefixed counterpart: `icontains`, `istarts_with`, and `iends_with`. + +```rust +use cot::db::Database; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn case_insensitive_search(db: Database) -> cot::Result<()> { + let customers = query!(Customer, $full_name.icontains("doe")).all(&db).await?; + println!("Customers: {:?}", customers); +# Ok(()) +} +``` + +These combine with the rest of the filter naturally too. Use them alongside boolean and comparison operators just like any other condition: + +```rust +use cot::db::Database; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn verified_does(db: Database) -> cot::Result<()> { + let customers = query!(Customer, $full_name.icontains("doe") && $is_verified == true) + .all(&db) + .await?; + println!("Customers: {:?}", customers); +# Ok(()) +} +``` + +#### Matching a custom pattern + +`contains`, `starts_with`, and `ends_with` cover most everyday searches, but they can't express every shape of match. What if you need to check both the start and the end of a value at once, or match a value that follows a specific structure? For cases like this, Cot provides `raw_like` (and its case-insensitive counterpart, `iraw_like`), which let you write the match pattern yourself, using a small glob-style syntax: + +- `*` matches zero or more of any character +- `?` matches exactly one character +- `\` escapes the character after it, so it's matched literally instead of as a wildcard + +Say you're searching for shipment tracking codes that start with `"PKG"` and end with `"US"`, with anything in between: + +```rust +use cot::db::Database; + +# #[model] struct Shipment { #[model(primary_key)] id: Auto, tracking_code: LimitedString<64>, is_delivered: bool } +async fn find_matching_tracking_codes(db: Database) -> cot::Result<()> { + // Matches "PKG-US", "PKG123-US", "PKGXXUS", and so on + let shipments = query!(Shipment, $tracking_code.raw_like("PKG*US")).all(&db).await?; + println!("Shipments: {:?}", shipments); +# Ok(()) +} +``` + +`raw_like` is more flexible than `contains`, `starts_with`, and `ends_with`, but it also means you're responsible for escaping any wildcard characters you want to match literally. See the [`Expr::raw_like`](enum@cot::db::query::expr::Expr#method.raw_like) docs for the full pattern syntax and escaping rules. + +#### Using the Expr struct directly + +As with the other operators in this guide, all of these have an equivalent on [`Expr`](enum@cot::db::query::expr::Expr) for when you're building queries by hand rather than through the `query!` macro: + +```rust +use cot::db::Database; +use cot::db::query::expr::Expr; + +# #[model] #[derive(Debug)] struct Customer { #[model(primary_key)] id: Auto, #[model(unique)] email: cot::common_types::Email, full_name: LimitedString<128>, is_verified: bool } +async fn search_customers_with_expr(db: Database) -> cot::Result<()> { + let customers = Customer::objects() + .filter(Expr::contains(Expr::field("full_name"), Expr::value("Doe"))) + .all(&db) + .await?; + println!("Customers: {:?}", customers); +# Ok(()) +} +``` + +For the complete list of pattern-matching methods, their case-insensitive counterparts, and the glob pattern syntax used by `raw_like`, see the [`Expr`](enum@cot::db::query::expr::Expr) and [`ExprLike`](trait@cot::db::query::expr::ExprLike) docs. + ## Removing an object The [`delete`](struct@cot::db::query::Query#method.delete) method can be used to remove an object from the database. The example below shows how to remove a `Customer` instance with the primary key of `5`. From a6f09cf223ec1be131709f863a37e71c1b3061a9 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 24 Jul 2026 02:47:08 +0000 Subject: [PATCH 18/24] fix minor doc-test --- docs/databases/queries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/databases/queries.md b/docs/databases/queries.md index 61e4db3d4..add451845 100644 --- a/docs/databases/queries.md +++ b/docs/databases/queries.md @@ -388,7 +388,7 @@ Say you're searching for shipment tracking codes that start with `"PKG"` and end ```rust use cot::db::Database; -# #[model] struct Shipment { #[model(primary_key)] id: Auto, tracking_code: LimitedString<64>, is_delivered: bool } +# #[model] #[derive(Debug)] struct Shipment { #[model(primary_key)] id: Auto, tracking_code: LimitedString<64>, is_delivered: bool } async fn find_matching_tracking_codes(db: Database) -> cot::Result<()> { // Matches "PKG-US", "PKG123-US", "PKGXXUS", and so on let shipments = query!(Shipment, $tracking_code.raw_like("PKG*US")).all(&db).await?; From d0126e6c3cbf20dd34c93c5d647b8f8b80d4c15f Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 24 Jul 2026 03:08:08 +0000 Subject: [PATCH 19/24] fixed ui tests --- cot-macros/src/query.rs | 2 +- .../tests/ui/func_query_field_ref_method_wrong_arity.stderr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index 1228f44d5..f562dc2ef 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -212,7 +212,7 @@ fn handle_field_ref_method( return syn::Error::new( span, format!( - "`{method_ident}` expects {arity} arguments, found {}", + "`{method_ident}` expects {arity} argument(s), found {}", args.len() ), ) diff --git a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr index 02c2ad35b..e14db35c7 100644 --- a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr +++ b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr @@ -1,4 +1,4 @@ -error: `contains` expects exactly 1 argument found 0 +error: `contains` expects exactly 1 argument(s), found 0 --> tests/ui/func_query_field_ref_method_wrong_arity.rs:11:5 | 11 | query!(MyModel, $name.contains()); From 5176ad639f20aa2a91f453725a4e3fc7dbc6a9f4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 24 Jul 2026 04:58:57 +0000 Subject: [PATCH 20/24] restrict ExprLike bounds for FieldRef to String column types --- cot/src/auth.rs | 3 +++ cot/src/common_types.rs | 5 +++++ cot/src/db.rs | 3 +++ cot/src/db/fields.rs | 6 +++++- cot/src/db/query/expr/like.rs | 5 +++-- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cot/src/auth.rs b/cot/src/auth.rs index d69d3c335..8e657d4cd 100644 --- a/cot/src/auth.rs +++ b/cot/src/auth.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; /// backwards compatible shim for form Password type. use async_trait::async_trait; use chrono::{DateTime, FixedOffset}; +use cot::db::TextField; use cot_core::error::impl_into_cot_error; use derive_more::with_trait::Debug; #[cfg(test)] @@ -730,6 +731,8 @@ impl ToDbValue for Option { } } +impl TextField for PasswordHash {} + /// Authentication helper structure. /// /// This is an object that provides methods to sign users in and out, by using diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index b0fe2ad21..4ffdcbb2e 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -20,6 +20,7 @@ use securer_string::SecureString; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::db::TextField; #[cfg(feature = "db")] use crate::db::{ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, ToDbValue}; @@ -459,6 +460,8 @@ impl DatabaseField for Url { const TYPE: ColumnType = ColumnType::Text; } +impl TextField for Url {} + /// A validated email address. /// /// This is a newtype wrapper around [`EmailAddress`] that provides validation @@ -805,6 +808,8 @@ impl Display for Email { } } +impl TextField for Email {} + #[cfg(test)] mod tests { use std::convert::TryFrom; diff --git a/cot/src/db.rs b/cot/src/db.rs index 792006d65..01031726f 100644 --- a/cot/src/db.rs +++ b/cot/src/db.rs @@ -1255,6 +1255,9 @@ pub trait SqlxValueRef<'r>: Sized { } } +/// Marker trait for DB field types that behave like texts. +pub trait TextField: ToDbFieldValue {} + /// A database connection structure that holds the connection to the database. /// /// It is used to execute queries and interact with the database. The connection diff --git a/cot/src/db/fields.rs b/cot/src/db/fields.rs index ce820e9fe..e420143b6 100644 --- a/cot/src/db/fields.rs +++ b/cot/src/db/fields.rs @@ -10,7 +10,7 @@ use crate::db::impl_postgres::PostgresValueRef; use crate::db::impl_sqlite::SqliteValueRef; use crate::db::{ Auto, ColumnType, DatabaseError, DatabaseField, DbFieldValue, DbValue, ForeignKey, FromDbValue, - LimitedString, Model, PrimaryKey, Result, SqlxValueRef, ToDbFieldValue, ToDbValue, + LimitedString, Model, PrimaryKey, Result, SqlxValueRef, TextField, ToDbFieldValue, ToDbValue, }; mod chrono_fields; @@ -248,6 +248,8 @@ impl_db_field!(Vec, Blob); impl_db_field!(Bytes, Blob, with Vec); +impl TextField for String {} + impl ToDbValue for &str { fn to_db_value(&self) -> DbValue { (*self).to_string().into() @@ -333,6 +335,8 @@ impl FromDbValue for Option> { } } +impl TextField for LimitedString {} + impl DatabaseField for ForeignKey { const NULLABLE: bool = T::PrimaryKey::NULLABLE; const TYPE: ColumnType = T::PrimaryKey::TYPE; diff --git a/cot/src/db/query/expr/like.rs b/cot/src/db/query/expr/like.rs index 089d41cd7..365683f9d 100644 --- a/cot/src/db/query/expr/like.rs +++ b/cot/src/db/query/expr/like.rs @@ -1,10 +1,11 @@ //! Database expressions for pattern-matching. -use cot::db::ToDbFieldValue; use cot::db::query::expr::{FieldRef, SqlQueryBuilder}; use cot::db::query::{Expr, QueryBuildingError}; use sea_query::{ExprTrait, SimpleExpr}; +use crate::db::TextField; + pub(crate) const LIKE_ESCAPE_CHAR: char = '\\'; /// A trait for database types that support literal-substring pattern @@ -58,7 +59,7 @@ pub trait ExprLike { fn iraw_like>(self, other: V) -> Expr; } -impl ExprLike for FieldRef { +impl ExprLike for FieldRef { fn contains>(self, other: V) -> Expr { Expr::contains(self.as_expr(), Expr::value(other.into())) } From 9b9dcc3c91a50c3a850f859c286617ffdf40e616 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 21:07:35 +0000 Subject: [PATCH 21/24] fix stderr ui tests --- cot-macros/src/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index f562dc2ef..2bd675caf 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -212,7 +212,7 @@ fn handle_field_ref_method( return syn::Error::new( span, format!( - "`{method_ident}` expects {arity} argument(s), found {}", + "`{method_ident}` expects exactly {arity} argument(s), found {}", args.len() ), ) From ef68cf6361736edee0eaa34675d46d58aa413a3a Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 21:24:14 +0000 Subject: [PATCH 22/24] Gate TextField behind db feature to fix cargo hack test --- cot/src/auth.rs | 6 ++++-- cot/src/common_types.rs | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cot/src/auth.rs b/cot/src/auth.rs index 8e657d4cd..63c73a9a3 100644 --- a/cot/src/auth.rs +++ b/cot/src/auth.rs @@ -16,7 +16,6 @@ use std::sync::{Arc, Mutex, MutexGuard}; /// backwards compatible shim for form Password type. use async_trait::async_trait; use chrono::{DateTime, FixedOffset}; -use cot::db::TextField; use cot_core::error::impl_into_cot_error; use derive_more::with_trait::Debug; #[cfg(test)] @@ -28,7 +27,9 @@ use thiserror::Error; use crate::config::SecretKey; #[cfg(feature = "db")] -use crate::db::{ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, ToDbValue}; +use crate::db::{ + ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, TextField, ToDbValue, +}; use crate::request::{Request, RequestExt}; use crate::session::Session; @@ -731,6 +732,7 @@ impl ToDbValue for Option { } } +#[cfg(feature = "db")] impl TextField for PasswordHash {} /// Authentication helper structure. diff --git a/cot/src/common_types.rs b/cot/src/common_types.rs index 4ffdcbb2e..0f7980e67 100644 --- a/cot/src/common_types.rs +++ b/cot/src/common_types.rs @@ -20,9 +20,10 @@ use securer_string::SecureString; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::db::TextField; #[cfg(feature = "db")] -use crate::db::{ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, ToDbValue}; +use crate::db::{ + ColumnType, DatabaseField, DbValue, FromDbValue, SqlxValueRef, TextField, ToDbValue, +}; // Maximum email length as specified in the RFC 5321 const MAX_EMAIL_LENGTH: u32 = 254; @@ -460,6 +461,7 @@ impl DatabaseField for Url { const TYPE: ColumnType = ColumnType::Text; } +#[cfg(feature = "db")] impl TextField for Url {} /// A validated email address. @@ -808,6 +810,7 @@ impl Display for Email { } } +#[cfg(feature = "db")] impl TextField for Email {} #[cfg(test)] From d017d696bef1b92ed830fd47d5b8f934157b9a38 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 28 Jul 2026 09:39:19 +0000 Subject: [PATCH 23/24] fix wrong arity stderr ui test --- .../tests/ui/func_query_field_ref_method_wrong_arity.stderr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr index e14db35c7..6ae55b896 100644 --- a/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr +++ b/cot-macros/tests/ui/func_query_field_ref_method_wrong_arity.stderr @@ -6,7 +6,7 @@ error: `contains` expects exactly 1 argument(s), found 0 | = note: this error originates in the macro `query` (in Nightly builds, run with -Z macro-backtrace for more info) -error: `contains` expects exactly 1 argument found 2 +error: `contains` expects exactly 1 argument(s), found 2 --> tests/ui/func_query_field_ref_method_wrong_arity.rs:12:36 | 12 | query!(MyModel, $name.contains("a", "b")); From 649ecfbc88211b3af5249920b49791dad94f02e8 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 28 Jul 2026 16:11:53 +0000 Subject: [PATCH 24/24] fix miri tests --- cot/src/db/impl_sqlite.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cot/src/db/impl_sqlite.rs b/cot/src/db/impl_sqlite.rs index 484c19ebc..abaf2e12a 100644 --- a/cot/src/db/impl_sqlite.rs +++ b/cot/src/db/impl_sqlite.rs @@ -140,6 +140,10 @@ mod tests { .to_string(SqliteQueryBuilder) } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_sensitive_uses_glob() { let db = test_db().await; @@ -149,6 +153,10 @@ mod tests { assert_where(expr, "\"name\" GLOB 'foo*'"); } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_sensitive_positional_pattern_translates_question_marks() { let db = test_db().await; @@ -158,6 +166,10 @@ mod tests { assert_where(expr, "\"name\" GLOB 'f??o'"); } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_sensitive_escapes_literal_wildcard_chars_for_glob() { let db = test_db().await; @@ -167,6 +179,10 @@ mod tests { assert_where(expr, "\"name\" GLOB '50[*]off'"); } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_insensitive_uses_lower_and_like() { let db = test_db().await; @@ -176,6 +192,10 @@ mod tests { assert_where(expr, "LOWER(\"name\") LIKE 'foo%' ESCAPE '\\'"); } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_insensitive_pattern_is_lowercased_before_conversion() { let db = test_db().await; @@ -185,6 +205,10 @@ mod tests { assert_where(expr, "LOWER(\"name\") LIKE 'readme' ESCAPE '\\'"); } + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2` on OS `linux`" + )] #[cot::test] async fn case_insensitive_includes_escape_clause() { let db = test_db().await;