From 3cfb8a269c07305ec5ef0ed637346e0c3a66c976 Mon Sep 17 00:00:00 2001 From: oxura Date: Sun, 30 Aug 2026 23:02:12 +0600 Subject: [PATCH] feat(db): support string concatenation in queries --- cot-codegen/src/expr.rs | 49 +++++++++++++-- cot-macros/src/query.rs | 60 ++++++++++++++++++- cot-macros/tests/query.rs | 25 ++++++++ ...query_field_ref_non_existing_method.stderr | 2 +- cot/src/db/query/expr.rs | 30 +++++++++- cot/tests/db_testing/query.rs | 51 ++++++++++++++++ 6 files changed, 207 insertions(+), 10 deletions(-) diff --git a/cot-codegen/src/expr.rs b/cot-codegen/src/expr.rs index c9a6ee83f..0658312c6 100644 --- a/cot-codegen/src/expr.rs +++ b/cot-codegen/src/expr.rs @@ -147,9 +147,47 @@ impl Parse for PathAccessParser { } } +/// An argument passed to a function or method in a query expression. +#[derive(Debug, PartialEq, Eq)] +pub enum ExprArgument { + /// A reference to a model field. + FieldRef { + field_name: syn::Ident, + field_token: Token![$], + }, + /// A regular Rust expression. + Value(syn::Expr), +} + +impl Parse for ExprArgument { + fn parse(input: ParseStream<'_>) -> syn::Result { + if input.peek(Token![$]) { + let field = input.parse::()?; + Ok(Self::FieldRef { + field_name: field.name, + field_token: field.field_token, + }) + } else { + input.parse().map(Self::Value) + } + } +} + +impl quote::ToTokens for ExprArgument { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::FieldRef { + field_name, + field_token, + } => tokens.extend(quote! { #field_token #field_name }), + Self::Value(value) => value.to_tokens(tokens), + } + } +} + #[derive(Debug)] struct FunctionCallParser { - args: syn::punctuated::Punctuated, + args: syn::punctuated::Punctuated, } impl FunctionCallParser { @@ -164,7 +202,7 @@ impl Parse for FunctionCallParser { let args_content; syn::parenthesized!(args_content in input); Ok(Self { - args: args_content.parse_terminated(syn::Expr::parse, Token![,])?, + args: args_content.parse_terminated(ExprArgument::parse, Token![,])?, }) } } @@ -323,7 +361,7 @@ pub enum Expr { }, FunctionCall { function: Box, - args: Vec, + args: Vec, }, And(Box, Box), Or(Box, Box), @@ -689,7 +727,10 @@ mod tests { Box::new(field("a")), Box::new(Expr::FunctionCall { function: Box::new(value("bar")), - args: vec![parse_quote!(42), parse_quote!("baz")], + args: vec![ + ExprArgument::Value(parse_quote!(42)), + ExprArgument::Value(parse_quote!("baz")), + ], }), ); diff --git a/cot-macros/src/query.rs b/cot-macros/src/query.rs index a4f059b8b..8e0f9981f 100644 --- a/cot-macros/src/query.rs +++ b/cot-macros/src/query.rs @@ -1,4 +1,4 @@ -use cot_codegen::expr::Expr; +use cot_codegen::expr::{Expr, ExprArgument}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::Token; @@ -62,6 +62,10 @@ const FIELD_REF_METHODS: &[FieldRefMethod] = &[ name: "iraw_like", arity: 1, }, + FieldRefMethod { + name: "concat", + arity: 1, + }, ]; impl FieldRefMethod { @@ -201,7 +205,7 @@ fn handle_binary_comparison( fn handle_field_ref_method( model_name: &syn::Type, receiver: Expr, - args: &[syn::Expr], + args: &[ExprArgument], method: FieldRefMethod, ) -> TokenStream { let crate_name = cot_ident(); @@ -222,6 +226,10 @@ fn handle_field_ref_method( .to_compile_error(); } + if method.name == "concat" { + return handle_concat(model_name, receiver, &args[0]); + } + if let Expr::FieldRef { ref field_name, .. } = receiver { return quote! { #crate_name::db::query::expr::ExprLike::#method_ident( @@ -244,6 +252,44 @@ fn handle_field_ref_method( } } +fn handle_concat(model_name: &syn::Type, receiver: Expr, argument: &ExprArgument) -> TokenStream { + let crate_name = cot_ident(); + + if let Expr::FieldRef { field_name, .. } = receiver { + return match argument { + ExprArgument::FieldRef { + field_name: argument_field, + .. + } => quote! { + #crate_name::db::query::expr::Expr::concat( + <#model_name as #crate_name::db::Model>::Fields::#field_name.as_expr(), + <#model_name as #crate_name::db::Model>::Fields::#argument_field.as_expr(), + ) + }, + ExprArgument::Value(value) => quote! { + #crate_name::db::query::expr::ExprConcat::concat( + <#model_name as #crate_name::db::Model>::Fields::#field_name, + #value, + ) + }, + }; + } + + let receiver_tokens = expr_to_tokens(model_name, receiver); + let argument_tokens = match argument { + ExprArgument::FieldRef { field_name, .. } => quote! { + <#model_name as #crate_name::db::Model>::Fields::#field_name.as_expr() + }, + ExprArgument::Value(value) => { + quote!(#crate_name::db::query::expr::Expr::value(#value)) + } + }; + + quote! { + #crate_name::db::query::expr::Expr::concat(#receiver_tokens, #argument_tokens) + } +} + #[cfg(test)] mod tests { use super::*; @@ -251,7 +297,7 @@ mod tests { #[test] fn test_field_ref_method_all_names() { let all_names = FieldRefMethod::all_names().collect::>(); - assert_eq!(all_names.len(), 8); + assert_eq!(all_names.len(), 9); assert_eq!( all_names, [ @@ -263,6 +309,7 @@ mod tests { "iends_with", "raw_like", "iraw_like", + "concat", ] ); } @@ -326,6 +373,13 @@ mod tests { arity: 1, }), ), + ( + "concat", + Some(FieldRefMethod { + name: "concat", + arity: 1, + }), + ), ("__non_existent__", None), ]; diff --git a/cot-macros/tests/query.rs b/cot-macros/tests/query.rs index 4b78671f9..ad6cfbe84 100644 --- a/cot-macros/tests/query.rs +++ b/cot-macros/tests/query.rs @@ -413,6 +413,31 @@ fn test_query_string_method_string_concat_field_refs() { ); } +#[test] +fn test_query_string_concat_fields_and_literals() { + assert_eq!( + Query::::new().filter(Expr::eq( + Expr::concat( + ::Fields::name.as_expr(), + ::Fields::title.as_expr() + ), + Expr::value("firstlast") + )), + query!(MyModel, $name.concat($title) == "firstlast") + ); + + assert_eq!( + Query::::new().filter(Expr::eq( + Expr::concat( + ::Fields::name.as_expr(), + Expr::value("-") + ), + Expr::value("first-") + )), + query!(MyModel, $name.concat("-") == "first-") + ); +} + #[test] fn test_query_string_method_non_field_receiver_call() { let allowed_names = &["foo", "bar"]; 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 index acaf69a0c..a683c71df 100644 --- 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 @@ -1,4 +1,4 @@ -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) +error: calling functions that reference database fields is unsupported (only `contains`, `icontains`, `starts_with`, `istarts_with`, `ends_with`, `iends_with`, `raw_like`, `iraw_like`, `concat` 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()); diff --git a/cot/src/db/query/expr.rs b/cot/src/db/query/expr.rs index 8946ccd95..5e0211296 100644 --- a/cot/src/db/query/expr.rs +++ b/cot/src/db/query/expr.rs @@ -4,10 +4,10 @@ pub mod like; use std::marker::PhantomData; use cot::db::query::{IntoField, QueryBuildingError}; -use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, ToDbFieldValue}; +use cot::db::{DbFieldValue, DbValue, FromDbValue, Identifier, TextField, ToDbFieldValue}; pub use like::ExprLike; use like::{CaseSensitivity, LikeExprBuilder, LikeMode}; -use sea_query::{ExprTrait, IntoColumnRef, SimpleExpr}; +use sea_query::{ExprTrait, Func, IntoColumnRef, SimpleExpr}; /// An expression that can be used to filter, update, or delete rows. /// @@ -296,6 +296,8 @@ pub enum Expr { /// ); /// ``` Add(Box, Box), + /// A string concatenation expression. + Concat(Box, Box), /// A `-` expression. /// /// # Example @@ -785,6 +787,14 @@ impl Expr { Self::Add(Box::new(lhs), Box::new(rhs)) } + /// Creates a string concatenation expression. + /// + /// This is translated to the portable SQL `CONCAT(lhs, rhs)` function. + #[must_use] + pub fn concat(lhs: Self, rhs: Self) -> Self { + Self::Concat(Box::new(lhs), Box::new(rhs)) + } + /// Create a new `-` expression. /// /// # Example @@ -1259,6 +1269,10 @@ impl Expr { Self::Add(lhs, rhs) => Ok(lhs .as_sea_query_expr(sql_builder)? .add(rhs.as_sea_query_expr(sql_builder)?)), + Self::Concat(lhs, rhs) => Ok(Func::cust(Identifier::new("CONCAT")) + .arg(lhs.as_sea_query_expr(sql_builder)?) + .arg(rhs.as_sea_query_expr(sql_builder)?) + .into()), Self::Sub(lhs, rhs) => Ok(lhs .as_sea_query_expr(sql_builder)? .sub(rhs.as_sea_query_expr(sql_builder)?)), @@ -1381,6 +1395,18 @@ impl ExprEq for FieldRef { } } +/// Concatenates a text database field with another text value. +pub trait ExprConcat { + /// Creates a string concatenation expression. + fn concat(self, other: V) -> Expr; +} + +impl ExprConcat for FieldRef { + fn concat(self, other: V) -> Expr { + Expr::concat(self.as_expr(), Expr::value(other)) + } +} + /// 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. diff --git a/cot/tests/db_testing/query.rs b/cot/tests/db_testing/query.rs index fa1b560a2..bff3a5abd 100644 --- a/cot/tests/db_testing/query.rs +++ b/cot/tests/db_testing/query.rs @@ -12,6 +12,15 @@ struct TestModel { name: String, } +#[derive(Debug, Clone, PartialEq)] +#[model] +struct ConcatModel { + #[model(primary_key)] + id: Auto, + first_name: String, + last_name: String, +} + // Check different types for the primary key #[derive(Debug, PartialEq)] #[model] @@ -59,6 +68,23 @@ const CREATE_TEST_MODEL: Operation = Operation::create_model() ]) .build(); +const CREATE_CONCAT_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__concat_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("first_name"), + ::TYPE, + ), + Field::new( + Identifier::new("last_name"), + ::TYPE, + ), + ]) + .build(); + #[cot_macros::dbtest] async fn model_crud(test_db: &mut TestDatabase) { migrate_test_model(&*test_db).await; @@ -94,6 +120,31 @@ async fn model_crud(test_db: &mut TestDatabase) { assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); } +#[cot_macros::dbtest] +async fn string_concat_filter(test_db: &mut TestDatabase) { + CREATE_CONCAT_MODEL.forwards(&*test_db).await.unwrap(); + + let mut model = ConcatModel { + id: Auto::auto(), + first_name: "Ada".to_owned(), + last_name: "Lovelace".to_owned(), + }; + model.insert(&**test_db).await.unwrap(); + + let with_separator = + query!(ConcatModel, $first_name.concat(" ").concat($last_name) == "Ada Lovelace") + .all(&**test_db) + .await + .unwrap(); + assert_eq!(with_separator, vec![model.clone()]); + + let fields_only = query!(ConcatModel, $first_name.concat($last_name) == "AdaLovelace") + .all(&**test_db) + .await + .unwrap(); + assert_eq!(fields_only, vec![model]); +} + #[cot_macros::dbtest] async fn model_insert(test_db: &mut TestDatabase) { migrate_test_model(&*test_db).await;