use sea_orm::{
DatabaseBackend,
sea_query::{Expr, SimpleExpr},
};
const MAX_SEARCH_QUERY_LENGTH: usize = 10_000;
fn escape_like_wildcards(input: &str) -> String {
input
.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") }
#[must_use]
pub fn build_fulltext_condition<T: crate::traits::CRUDResource>(
query: &str,
backend: DatabaseBackend,
) -> Option<SimpleExpr> {
let fulltext_columns = T::fulltext_searchable_columns();
if fulltext_columns.is_empty() {
return None;
}
let column_names: Vec<&'static str> = fulltext_columns.iter().map(|(name, _)| *name).collect();
match backend {
DatabaseBackend::Postgres => build_postgres_fulltext_condition(query, &column_names),
DatabaseBackend::MySql => build_mysql_fulltext_condition(query, &column_names),
DatabaseBackend::Sqlite => build_fallback_fulltext_condition(query, &column_names),
}
}
fn build_postgres_fulltext_condition(
query: &str,
column_names: &[&'static str],
) -> Option<SimpleExpr> {
if column_names.is_empty() || query.is_empty() {
return None;
}
let concat_sql = column_names
.iter()
.map(|name| format!("COALESCE({name}::text, '')"))
.collect::<Vec<_>>()
.join(" || ' ' || ");
let sanitized = query[..query.len().min(MAX_SEARCH_QUERY_LENGTH)].trim();
let pattern = format!("%{}%", escape_like_wildcards(sanitized));
Some(Expr::cust_with_values(
format!("({concat_sql}) ILIKE ? ESCAPE '\\'"),
[pattern],
))
}
fn build_mysql_fulltext_condition(
query: &str,
column_names: &[&'static str],
) -> Option<SimpleExpr> {
if column_names.is_empty() || query.is_empty() {
return None;
}
let coalesced: Vec<String> = column_names
.iter()
.map(|name| format!("COALESCE(CAST({name} AS CHAR), '')"))
.collect();
let concat_sql = if coalesced.len() == 1 {
coalesced[0].clone()
} else {
format!("CONCAT({})", coalesced.join(", ' ', "))
};
let sanitized = query[..query.len().min(MAX_SEARCH_QUERY_LENGTH)].trim();
let pattern = format!("%{}%", escape_like_wildcards(sanitized).to_uppercase());
Some(Expr::cust_with_values(
format!("UPPER({concat_sql}) LIKE ? ESCAPE '\\\\'"),
[pattern],
))
}
fn build_fallback_fulltext_condition(
query: &str,
column_names: &[&'static str],
) -> Option<SimpleExpr> {
if column_names.is_empty() || query.is_empty() {
return None;
}
let concat_sql = column_names
.iter()
.map(|name| format!("CAST({name} AS TEXT)"))
.collect::<Vec<_>>()
.join(" || ' ' || ");
let sanitized = query[..query.len().min(MAX_SEARCH_QUERY_LENGTH)].trim();
let pattern = format!("%{}%", escape_like_wildcards(sanitized).to_uppercase());
Some(Expr::cust_with_values(
format!("UPPER({concat_sql}) LIKE ? ESCAPE '\\'"),
[pattern],
))
}
#[must_use]
pub fn build_like_condition(key: &str, trimmed_value: &str) -> SimpleExpr {
use sea_orm::sea_query::{Alias, Expr, ExprTrait, Func};
let column = Expr::col(Alias::new(key));
let escaped_value = escape_like_wildcards(trimmed_value);
let pattern = format!("%{}%", escaped_value.to_uppercase());
Func::upper(column).like(pattern)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_column_names_use_expr_col() {
let result = build_like_condition("user_name", "test");
let sql = format!("{result:?}");
assert!(
sql.contains("Column(") && sql.contains("user_name"),
"Column should be wrapped in Column() AST node, got: {sql}"
);
}
#[test]
fn test_column_names_wrapped_safely() {
let result = build_like_condition("test_column", "value");
let sql = format!("{result:?}");
assert!(sql.contains("Column("), "Should use Expr::col() wrapper");
}
#[test]
fn test_search_query_value_safe() {
let malicious_values = vec!["'; DROP TABLE users; --", "' OR '1'='1"];
for malicious_value in malicious_values {
let result = build_like_condition("title", malicious_value);
let sql = format!("{result:?}");
assert!(
sql.contains("Value(String"),
"Values should be wrapped safely: {sql}"
);
}
}
#[test]
fn test_search_query_length_limit() {
let very_long_query = "a".repeat(20_000);
let sanitized = &very_long_query[..very_long_query.len().min(MAX_SEARCH_QUERY_LENGTH)];
assert!(
sanitized.len() <= MAX_SEARCH_QUERY_LENGTH,
"Query should be truncated to max length"
);
}
#[test]
fn test_wildcard_escaping() {
assert_eq!(
escape_like_wildcards("test"),
"test",
"Normal text should pass through"
);
assert_eq!(
escape_like_wildcards("test%"),
"test\\%",
"% should be escaped"
);
assert_eq!(
escape_like_wildcards("test_value"),
"test\\_value",
"_ should be escaped"
);
assert_eq!(
escape_like_wildcards("100%"),
"100\\%",
"% in middle should be escaped"
);
assert_eq!(
escape_like_wildcards("%_"),
"\\%\\_",
"Both wildcards should be escaped"
);
assert_eq!(
escape_like_wildcards("\\"),
"\\\\",
"Backslash should be escaped"
);
assert_eq!(
escape_like_wildcards("\\%"),
"\\\\\\%",
"Backslash and % should both be escaped"
);
}
#[test]
fn test_like_condition_prevents_wildcard_injection() {
let result_percent = build_like_condition("title", "test%");
let sql_percent = format!("{result_percent:?}");
assert!(
sql_percent.contains("\\\\%"),
"% should be escaped in SQL: {sql_percent}"
);
let result_underscore = build_like_condition("title", "test_value");
let sql_underscore = format!("{result_underscore:?}");
assert!(
sql_underscore.contains("\\\\_"),
"_ should be escaped in SQL: {sql_underscore}"
);
let result_just_percent = build_like_condition("title", "%");
let sql_just_percent = format!("{result_just_percent:?}");
assert!(
sql_just_percent.contains("\\\\%"),
"Single % should be escaped: {sql_just_percent}"
);
}
#[test]
fn test_build_like_condition_empty_value() {
let result = build_like_condition("field", "");
let sql = format!("{result:?}");
assert!(sql.contains("field"), "Should include field name");
}
#[test]
fn test_build_like_condition_case_insensitive() {
let result = build_like_condition("title", "TeSt");
let sql = format!("{result:?}");
assert!(
sql.contains("Upper") || sql.contains("UPPER"),
"Should use UPPER for case insensitivity: {sql}"
);
}
#[test]
fn test_build_like_condition_special_chars() {
let result = build_like_condition("title", "test@email.com");
let sql = format!("{result:?}");
assert!(sql.contains("title"), "Should handle special characters");
}
#[test]
fn test_like_condition_empty_query_matches_all() {
let result = build_like_condition("field", "");
let sql = format!("{result:?}");
assert!(
sql.contains("%%") || sql.contains("%\""),
"Empty query should produce match-all pattern"
);
}
#[test]
fn test_like_condition_whitespace_query() {
let result = build_like_condition("field", " ");
let sql = format!("{result:?}");
assert!(sql.contains("field"), "Should include field name");
}
#[test]
fn test_like_condition_case_insensitive_pattern() {
let result = build_like_condition("field", "MiXeD CaSe");
let sql = format!("{result:?}");
assert!(
sql.contains("MIXED CASE"),
"Pattern should be uppercased for case-insensitive match: {}",
sql
);
}
#[test]
fn test_max_search_query_length_constant() {
assert_eq!(
MAX_SEARCH_QUERY_LENGTH, 10_000,
"Max query length should be 10,000"
);
}
#[test]
fn test_escape_like_wildcards_empty() {
assert_eq!(
escape_like_wildcards(""),
"",
"Empty string should pass through"
);
}
fn split_custom_with_expr(debug: &str) -> (&str, &str) {
let prefix = "CustomWithExpr(\"";
let start = debug
.find(prefix)
.map(|i| i + prefix.len())
.expect("not a CustomWithExpr");
let split = debug
.find("\", [")
.expect("CustomWithExpr without values section");
(&debug[start..split], &debug[split + 4..])
}
#[test]
fn test_postgres_fulltext_binds_query_value() {
let malicious = "'; DROP TABLE users; --";
let result = build_postgres_fulltext_condition(malicious, &["name", "email"])
.expect("non-empty input produces a condition");
let debug = format!("{result:?}");
assert!(debug.starts_with("CustomWithExpr"), "got {debug}");
let (template, values) = split_custom_with_expr(&debug);
assert!(
template.contains("ILIKE ?"),
"template must use a placeholder, got: {template}"
);
assert!(
!template.contains(malicious) && !template.contains("DROP TABLE"),
"malicious value must not appear in SQL template: {template}"
);
assert!(
values.contains("Value(String") && values.contains("DROP TABLE"),
"query value must be bound as a parameter, got: {values}"
);
}
#[test]
fn test_mysql_fulltext_binds_query_value() {
let malicious = "' OR '1'='1";
let result = build_mysql_fulltext_condition(malicious, &["name", "email"])
.expect("non-empty input produces a condition");
let debug = format!("{result:?}");
assert!(debug.starts_with("CustomWithExpr"), "got {debug}");
let (template, values) = split_custom_with_expr(&debug);
assert!(
template.contains("LIKE ?"),
"template must use a placeholder, got: {template}"
);
assert!(
!template.contains(malicious),
"malicious value must not appear in SQL template: {template}"
);
assert!(
values.contains("Value(String"),
"query value must be bound as a parameter, got: {values}"
);
}
#[test]
fn test_fallback_fulltext_binds_query_value() {
let malicious = "'; DELETE FROM customers; --";
let result = build_fallback_fulltext_condition(malicious, &["name"])
.expect("non-empty input produces a condition");
let debug = format!("{result:?}");
assert!(debug.starts_with("CustomWithExpr"), "got {debug}");
let (template, values) = split_custom_with_expr(&debug);
assert!(
template.contains("LIKE ?"),
"template must use a placeholder, got: {template}"
);
assert!(
!template.contains(malicious) && !template.contains("DELETE FROM"),
"malicious value must not appear in SQL template: {template}"
);
assert!(
values.contains("Value(String"),
"query value must be bound as a parameter, got: {values}"
);
}
#[test]
fn test_postgres_fulltext_escapes_like_wildcards() {
let result = build_postgres_fulltext_condition("100%", &["name"])
.expect("non-empty input produces a condition");
let debug = format!("{result:?}");
assert!(
debug.contains("100\\\\%") || debug.contains("100\\%"),
"expected LIKE wildcard escaped in pattern, got {debug}"
);
}
#[test]
fn test_fulltext_empty_columns_returns_none() {
assert!(build_postgres_fulltext_condition("foo", &[]).is_none());
assert!(build_mysql_fulltext_condition("foo", &[]).is_none());
assert!(build_fallback_fulltext_condition("foo", &[]).is_none());
}
#[test]
fn test_fulltext_empty_query_returns_none() {
assert!(build_postgres_fulltext_condition("", &["name"]).is_none());
assert!(build_mysql_fulltext_condition("", &["name"]).is_none());
assert!(build_fallback_fulltext_condition("", &["name"]).is_none());
}
}