use sea_orm::{
DatabaseBackend,
sea_query::{Expr, SimpleExpr},
};
const MAX_SEARCH_QUERY_LENGTH: usize = 10_000;
pub(crate) fn escape_like_wildcards(input: &str) -> String {
input
.replace('!', "!!")
.replace('%', "!%")
.replace('_', "!_")
}
fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[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 = truncate_to_char_boundary(query, MAX_SEARCH_QUERY_LENGTH).trim();
let pattern = format!("%{}%", escape_like_wildcards(sanitized));
Some(Expr::cust_with_values(
format!("({concat_sql}) ILIKE $1 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 = truncate_to_char_boundary(query, 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 = truncate_to_char_boundary(query, 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,
backend: DatabaseBackend,
) -> SimpleExpr {
let escaped_value = escape_like_wildcards(trimmed_value);
let pattern = format!("%{}%", escaped_value.to_uppercase());
let placeholder = placeholder_for(backend);
Expr::cust_with_values(
format!("UPPER({key}) LIKE {placeholder} ESCAPE '!'"),
[pattern],
)
}
fn placeholder_for(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => "$1",
_ => "?",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_column_name_in_template_value_bound() {
let result = build_like_condition("user_name", "test", DatabaseBackend::Sqlite);
let debug = format!("{result:?}");
assert!(debug.starts_with("CustomWithExpr"), "got {debug}");
let (template, values) = split_custom_with_expr(&debug);
assert!(
template.contains("UPPER(user_name)"),
"column should appear in SQL template, got: {template}"
);
assert!(
template.contains("LIKE ? ESCAPE '!'"),
"template should use bind param with ESCAPE, got: {template}"
);
assert!(
values.contains("Value(String"),
"search value should be a bind parameter, got: {values}"
);
}
#[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, DatabaseBackend::Sqlite);
let debug = format!("{result:?}");
assert!(debug.starts_with("CustomWithExpr"), "got {debug}");
let (template, values) = split_custom_with_expr(&debug);
assert!(
!template.contains(malicious_value),
"malicious value must not appear in SQL template: {template}"
);
assert!(
values.contains("Value(String"),
"value should be a bind parameter: {values}"
);
}
}
#[test]
fn test_search_query_length_limit() {
let very_long_query = "a".repeat(20_000);
let sanitized = truncate_to_char_boundary(&very_long_query, MAX_SEARCH_QUERY_LENGTH);
assert_eq!(
sanitized.len(),
MAX_SEARCH_QUERY_LENGTH,
"ASCII query should truncate exactly to the byte limit"
);
let multibyte = format!("{}é", "a".repeat(MAX_SEARCH_QUERY_LENGTH - 1));
let sanitized = truncate_to_char_boundary(&multibyte, MAX_SEARCH_QUERY_LENGTH);
assert!(
sanitized.len() <= MAX_SEARCH_QUERY_LENGTH,
"multi-byte query must stay within the byte limit, got {}",
sanitized.len()
);
assert!(
multibyte.is_char_boundary(sanitized.len()),
"truncation must end on a char boundary, got len {}",
sanitized.len()
);
assert_eq!(
sanitized.len(),
MAX_SEARCH_QUERY_LENGTH - 1,
"boundary should snap back past the start of the `é`"
);
assert_eq!(
truncate_to_char_boundary("short", MAX_SEARCH_QUERY_LENGTH),
"short"
);
}
#[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("!"),
"!!",
"Escape character itself should be escaped"
);
assert_eq!(
escape_like_wildcards("!%"),
"!!!%",
"Escape char and % should both be escaped"
);
}
#[test]
fn test_like_condition_prevents_wildcard_injection() {
let result_percent = build_like_condition("title", "test%", DatabaseBackend::Sqlite);
let debug_percent = format!("{result_percent:?}");
let (_, values_percent) = split_custom_with_expr(&debug_percent);
assert!(
values_percent.contains("TEST!%"),
"% should be escaped with ! in bound value: {values_percent}"
);
let result_underscore =
build_like_condition("title", "test_value", DatabaseBackend::Sqlite);
let debug_underscore = format!("{result_underscore:?}");
let (_, values_underscore) = split_custom_with_expr(&debug_underscore);
assert!(
values_underscore.contains("TEST!_VALUE"),
"_ should be escaped with ! in bound value: {values_underscore}"
);
let result_just_percent = build_like_condition("title", "%", DatabaseBackend::Sqlite);
let debug_just_percent = format!("{result_just_percent:?}");
let (_, values_just_percent) = split_custom_with_expr(&debug_just_percent);
assert!(
values_just_percent.contains("!%"),
"Single % should be escaped: {values_just_percent}"
);
}
#[test]
fn test_build_like_condition_empty_value() {
let result = build_like_condition("field", "", DatabaseBackend::Sqlite);
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", DatabaseBackend::Sqlite);
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", DatabaseBackend::Sqlite);
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", "", DatabaseBackend::Sqlite);
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", " ", DatabaseBackend::Sqlite);
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", DatabaseBackend::Sqlite);
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 $1"),
"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!%"),
"expected LIKE wildcard escaped with ! 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());
}
#[test]
fn test_fulltext_multibyte_truncation_does_not_panic() {
let query = format!("{}é", "a".repeat(MAX_SEARCH_QUERY_LENGTH - 1));
assert!(query.len() > MAX_SEARCH_QUERY_LENGTH);
assert!(build_postgres_fulltext_condition(&query, &["name"]).is_some());
assert!(build_mysql_fulltext_condition(&query, &["name"]).is_some());
assert!(build_fallback_fulltext_condition(&query, &["name"]).is_some());
}
#[test]
fn test_build_like_condition_postgres_placeholder() {
let result = build_like_condition("title", "x", DatabaseBackend::Postgres);
let debug = format!("{result:?}");
let (template, _values) = split_custom_with_expr(&debug);
assert!(
template.contains("LIKE $1 ESCAPE '!'"),
"Postgres must use $1 placeholder, got: {template}"
);
}
#[test]
fn test_mysql_fulltext_single_column_no_concat() {
let result = build_mysql_fulltext_condition("hello", &["name"])
.expect("non-empty input produces a condition");
let debug = format!("{result:?}");
let (template, _values) = split_custom_with_expr(&debug);
assert!(
!template.contains("CONCAT"),
"single-column MySQL fulltext should not wrap in CONCAT: {template}"
);
assert!(
template.contains("COALESCE"),
"expected COALESCE in template: {template}"
);
}
}
#[cfg(test)]
mod prop_tests {
use super::*;
use proptest::prelude::*;
fn unescape_like(escaped: &str) -> String {
let mut out = String::new();
let mut chars = escaped.chars();
while let Some(c) = chars.next() {
if c == '!' {
if let Some(n) = chars.next() {
out.push(n);
}
} else {
out.push(c);
}
}
out
}
proptest! {
#[test]
fn escape_like_wildcards_is_lossless_and_neutralises_wildcards(s in ".*") {
let escaped = escape_like_wildcards(&s);
prop_assert_eq!(unescape_like(&escaped), s.clone());
let chars: Vec<char> = escaped.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '%' || c == '_' {
prop_assert!(
i > 0 && chars[i - 1] == '!',
"wildcard {:?} at {} not escaped in {:?}",
c, i, escaped
);
}
}
}
#[test]
fn truncate_to_char_boundary_never_panics(s in ".*", n in 0usize..64) {
let t = truncate_to_char_boundary(&s, n);
prop_assert!(t.len() <= n);
prop_assert!(s.starts_with(t));
}
}
}