aster_forge_db/
search_query.rs1use sea_orm::ExprTrait;
10use sea_orm::sea_query::{
11 Alias, Expr, Func, IntoColumnRef, LikeExpr, Query, SimpleExpr, extension::sqlite::SqliteExpr,
12};
13
14#[must_use]
21pub fn escape_like_query(query: &str) -> String {
22 query
23 .replace('\\', "\\\\")
24 .replace('%', "\\%")
25 .replace('_', "\\_")
26}
27
28pub fn lower_like_condition(column: impl IntoColumnRef + Copy, query: &str) -> SimpleExpr {
33 let mut pattern = String::with_capacity(query.len() + 2);
34 pattern.push('%');
35 for ch in query.chars() {
36 match ch {
37 '\\' => pattern.push_str("\\\\"),
38 '%' => pattern.push_str("\\%"),
39 '_' => pattern.push_str("\\_"),
40 _ => pattern.extend(ch.to_lowercase()),
41 }
42 }
43 pattern.push('%');
44 Expr::expr(Func::lower(Expr::col(column))).like(LikeExpr::new(pattern).escape('\\'))
45}
46
47#[must_use]
49pub fn sqlite_match_query(query: &str) -> Option<String> {
50 if query.chars().count() < 3 {
51 return None;
52 }
53
54 Some(format!("\"{}\"", query.replace('"', "\"\"")))
55}
56
57#[must_use]
59pub fn mysql_boolean_mode_query(query: &str) -> Option<String> {
60 if query.chars().count() < 3 || query.chars().any(|ch| !ch.is_alphanumeric()) {
61 return None;
62 }
63
64 let escaped = query.replace('\\', "\\\\").replace('"', "\\\"");
65 Some(format!("\"{escaped}\""))
66}
67
68pub fn sqlite_fts_match_condition(
70 id_column: impl IntoColumnRef + Copy,
71 fts_table: &str,
72 match_query: &str,
73) -> SimpleExpr {
74 Expr::col(id_column).in_subquery(
75 Query::select()
76 .expr(Expr::col(Alias::new("rowid")))
77 .from(Alias::new(fts_table))
78 .and_where(Expr::col(Alias::new(fts_table)).matches(Expr::val(match_query)))
79 .to_owned(),
80 )
81}
82
83#[cfg(test)]
84mod tests {
85 use super::{
86 escape_like_query, lower_like_condition, mysql_boolean_mode_query, sqlite_match_query,
87 };
88 use sea_orm::sea_query::{
89 Alias, MysqlQueryBuilder, PostgresQueryBuilder, Query, SqliteQueryBuilder, Value,
90 };
91
92 #[derive(Copy, Clone)]
93 struct NameColumn;
94
95 impl sea_orm::sea_query::Iden for NameColumn {
96 fn unquoted(&self) -> &'static str {
97 "name"
98 }
99 }
100
101 #[test]
102 fn escape_like_query_escapes_wildcards() {
103 assert_eq!(escape_like_query("100%_done"), "100\\%\\_done");
104 }
105
106 #[test]
107 fn escape_like_query_escapes_backslash_before_wildcards() {
108 assert_eq!(escape_like_query("a\\%"), "a\\\\\\%");
111 assert_eq!(escape_like_query("a\\_b"), "a\\\\\\_b");
112 assert_eq!(escape_like_query("a\\b"), "a\\\\b");
113 }
114
115 #[test]
116 fn escape_like_query_escapes_trailing_backslash() {
117 assert_eq!(escape_like_query("a\\"), "a\\\\");
120 assert_eq!(escape_like_query("\\"), "\\\\");
121 }
122
123 fn like_condition_parts(
124 query: &str,
125 builder: impl sea_orm::sea_query::QueryBuilder,
126 ) -> (String, Vec<Value>) {
127 let (sql, values) = Query::select()
128 .column(Alias::new("name"))
129 .from(Alias::new("items"))
130 .and_where(lower_like_condition(NameColumn, query))
131 .build(builder);
132 (sql, values.0)
133 }
134
135 #[test]
136 fn lower_like_condition_declares_escape_clause_on_all_backends() {
137 for (sql, _) in [
138 like_condition_parts("report", SqliteQueryBuilder),
139 like_condition_parts("report", MysqlQueryBuilder),
140 like_condition_parts("report", PostgresQueryBuilder),
141 ] {
142 assert!(sql.contains("ESCAPE"), "expected ESCAPE clause in: {sql}");
143 }
144 }
145
146 #[test]
147 fn lower_like_condition_binds_fully_escaped_pattern_on_all_backends() {
148 let expected = vec![Value::String(Some("%a\\\\\\%\\_b%".to_string()))];
151 for (sql, values) in [
152 like_condition_parts("a\\%_B", SqliteQueryBuilder),
153 like_condition_parts("a\\%_B", MysqlQueryBuilder),
154 like_condition_parts("a\\%_B", PostgresQueryBuilder),
155 ] {
156 assert!(sql.contains("LIKE"), "expected LIKE in: {sql}");
157 assert_eq!(values, expected);
158 }
159 }
160
161 #[test]
162 fn lower_like_condition_renders_sqlite_sql_with_escape_clause() {
163 let sql = Query::select()
164 .column(Alias::new("name"))
165 .from(Alias::new("items"))
166 .and_where(lower_like_condition(NameColumn, "a\\%"))
167 .to_string(SqliteQueryBuilder);
168 assert_eq!(
169 sql,
170 r#"SELECT "name" FROM "items" WHERE LOWER("name") LIKE '%a\\\%%' ESCAPE '\'"#
171 );
172 }
173
174 #[test]
175 fn sqlite_match_query_wraps_multi_character_input_in_phrase_quotes() {
176 assert_eq!(sqlite_match_query("report"), Some("\"report\"".into()));
177 assert_eq!(
178 sqlite_match_query("report\"2026"),
179 Some("\"report\"\"2026\"".into())
180 );
181 }
182
183 #[test]
184 fn sqlite_match_query_falls_back_for_short_input() {
185 assert_eq!(sqlite_match_query("r"), None);
186 assert_eq!(sqlite_match_query("re"), None);
187 }
188
189 #[test]
190 fn mysql_boolean_mode_query_uses_phrase_search_for_multi_char_input() {
191 assert_eq!(
192 mysql_boolean_mode_query("report"),
193 Some("\"report\"".into())
194 );
195 assert_eq!(
196 mysql_boolean_mode_query("report2026"),
197 Some("\"report2026\"".into())
198 );
199 }
200
201 #[test]
202 fn mysql_boolean_mode_query_falls_back_for_invalid_input() {
203 assert_eq!(mysql_boolean_mode_query("r"), None);
204 assert_eq!(mysql_boolean_mode_query("re"), None);
205 assert_eq!(mysql_boolean_mode_query("re-port"), None);
206 }
207}