nodedb_sql/parser/preprocess/
search_vector.rs1use super::lex::{find_ascii_case_insensitive, find_operator_positions};
15
16const SEARCH_KEYWORD: &str = "SEARCH";
17const USING_KEYWORD: &str = "USING";
18const VECTOR_KEYWORD: &str = "VECTOR";
19
20pub fn try_rewrite_search_using_vector(sql: &str) -> Option<String> {
21 let trimmed = sql.trim_end_matches(|c: char| c == ';' || c.is_whitespace());
22 let leading = trimmed.len() - trimmed.trim_start().len();
23 let (select, consumed) = parse_search_clause(&trimmed[leading..])?;
24 if !trimmed[leading + consumed..].trim().is_empty() {
27 return None;
28 }
29
30 let trailing = &sql[trimmed.len()..];
31 Some(format!("{select}{trailing}"))
32}
33
34pub fn try_rewrite_nested_search_using_vector(sql: &str) -> Option<String> {
39 find_ascii_case_insensitive(sql, SEARCH_KEYWORD)?;
40
41 let mut out = String::new();
42 let mut copied = 0usize;
43 for open in find_operator_positions(sql, "(") {
44 if open < copied {
45 continue;
46 }
47 let after_open = open + 1;
48 let inner = &sql[after_open..];
49 let leading = inner.len() - inner.trim_start().len();
50 let Some((select, consumed)) = parse_search_clause(&inner[leading..]) else {
51 continue;
52 };
53 out.push_str(&sql[copied..after_open]);
54 out.push_str(&select);
55 copied = after_open + leading + consumed;
56 }
57 if copied == 0 {
58 return None;
59 }
60 out.push_str(&sql[copied..]);
61 Some(out)
62}
63
64fn parse_search_clause(input: &str) -> Option<(String, usize)> {
69 let after_search = keyword_rest(input, SEARCH_KEYWORD)?;
70 let (collection, rest) = take_identifier(after_search.trim_start())?;
71 let after_using = keyword_rest(rest, USING_KEYWORD)?;
72 let after_vector = keyword_rest(after_using, VECTOR_KEYWORD)?;
73 let (body, consumed_args) = take_parenthesized(after_vector)?;
74 let (field, vector_expr, limit) = split_vector_args(body)?;
75
76 let order_by = match field {
77 Some(name) => format!("vector_distance({name}, {vector_expr})"),
78 None => format!("vector_distance({vector_expr})"),
79 };
80 let select = format!("SELECT * FROM {collection} ORDER BY {order_by} LIMIT {limit}");
81 let consumed = input.len() - after_vector.len() + consumed_args;
82 Some((select, consumed))
83}
84
85fn keyword_rest<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
88 let trimmed = input.trim_start();
89 let bytes = trimmed.as_bytes();
93 if bytes.len() < keyword.len()
94 || !bytes[..keyword.len()].eq_ignore_ascii_case(keyword.as_bytes())
95 {
96 return None;
97 }
98 let rest = &trimmed[keyword.len()..];
99 if rest.chars().next().is_some_and(is_ident_char) {
100 return None;
101 }
102 Some(rest.trim_start())
103}
104
105fn take_identifier(input: &str) -> Option<(&str, &str)> {
110 if input.starts_with('"') {
111 return take_quoted_identifier(input);
112 }
113 let end = input
114 .char_indices()
115 .find(|(_, c)| !is_ident_char(*c))
116 .map(|(i, _)| i)
117 .unwrap_or(input.len());
118 if end == 0 {
119 return None;
120 }
121 Some((&input[..end], &input[end..]))
122}
123
124fn take_quoted_identifier(input: &str) -> Option<(&str, &str)> {
127 let mut chars = input.char_indices().skip(1);
128 while let Some((offset, c)) = chars.next() {
129 if c != '"' {
130 continue;
131 }
132 if input[offset + 1..].starts_with('"') {
134 chars.next();
135 continue;
136 }
137 let end = offset + 1;
138 if end == 2 {
140 return None;
141 }
142 return Some((&input[..end], &input[end..]));
143 }
144 None
145}
146
147fn is_ident_char(c: char) -> bool {
148 c.is_ascii_alphanumeric() || c == '_'
149}
150
151fn take_parenthesized(input: &str) -> Option<(&str, usize)> {
155 if !input.starts_with('(') {
156 return None;
157 }
158 let mut depth = 0i32;
159 let mut in_single = false;
160 let mut in_double = false;
161 for (offset, c) in input.char_indices() {
162 match c {
163 '\'' if !in_double => in_single = !in_single,
164 '"' if !in_single => in_double = !in_double,
165 '(' if !in_single && !in_double => depth += 1,
166 ')' if !in_single && !in_double => {
167 depth -= 1;
168 if depth == 0 {
169 return Some((input[1..offset].trim(), offset + 1));
170 }
171 }
172 _ => {}
173 }
174 }
175 None
176}
177
178fn split_vector_args(body: &str) -> Option<(Option<String>, String, String)> {
179 let parts = split_top_level_commas(body);
180 match parts.as_slice() {
181 [field, vec, k] => {
182 let field = field.trim();
183 let trimmed = if field.is_empty() {
184 None
185 } else {
186 Some(field.to_string())
187 };
188 Some((trimmed, vec.trim().to_string(), k.trim().to_string()))
189 }
190 [vec, k] => Some((None, vec.trim().to_string(), k.trim().to_string())),
191 _ => None,
192 }
193}
194
195fn split_top_level_commas(body: &str) -> Vec<String> {
196 let mut depth_paren = 0i32;
197 let mut depth_bracket = 0i32;
198 let mut in_single = false;
199 let mut in_double = false;
200 let mut current = String::new();
201 let mut out = Vec::new();
202 for c in body.chars() {
203 match c {
204 '\'' if !in_double => in_single = !in_single,
205 '"' if !in_single => in_double = !in_double,
206 '(' if !in_single && !in_double => depth_paren += 1,
207 ')' if !in_single && !in_double => depth_paren -= 1,
208 '[' if !in_single && !in_double => depth_bracket += 1,
209 ']' if !in_single && !in_double => depth_bracket -= 1,
210 ',' if !in_single && !in_double && depth_paren == 0 && depth_bracket == 0 => {
211 out.push(std::mem::take(&mut current));
212 continue;
213 }
214 _ => {}
215 }
216 current.push(c);
217 }
218 if !current.trim().is_empty() {
219 out.push(current);
220 }
221 out
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn rewrites_three_arg_form() {
230 let out = try_rewrite_search_using_vector(
231 "SEARCH articles USING VECTOR(embedding, ARRAY[0.1, 0.3, -0.2], 10)",
232 )
233 .unwrap();
234 assert_eq!(
235 out,
236 "SELECT * FROM articles ORDER BY vector_distance(embedding, ARRAY[0.1, 0.3, -0.2]) LIMIT 10"
237 );
238 }
239
240 #[test]
241 fn unicode_case_expansions_in_vector_expression_preserve_original_text() {
242 let out = try_rewrite_search_using_vector(
243 "SEARCH docs USING VECTOR(embedding, ARRAY['ß', 'ff', 'İ'], 5)",
244 )
245 .expect("search vector form should rewrite");
246 assert!(out.contains("ARRAY['ß', 'ff', 'İ']"));
247 }
248
249 #[test]
250 fn rewrites_two_arg_form_when_field_omitted() {
251 let out =
252 try_rewrite_search_using_vector("SEARCH articles USING VECTOR(ARRAY[0.1, 0.3], 5)")
253 .unwrap();
254 assert_eq!(
255 out,
256 "SELECT * FROM articles ORDER BY vector_distance(ARRAY[0.1, 0.3]) LIMIT 5"
257 );
258 }
259
260 #[test]
261 fn returns_none_when_not_search() {
262 assert!(try_rewrite_search_using_vector("SELECT * FROM t").is_none());
263 }
264
265 #[test]
266 fn returns_none_when_using_fusion() {
267 assert!(try_rewrite_search_using_vector("SEARCH c USING FUSION(ARRAY[0.5])").is_none());
268 }
269
270 #[test]
271 fn rewrites_search_in_derived_table_position() {
272 let out = try_rewrite_nested_search_using_vector(
273 "SELECT * FROM (SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2)) s WHERE s.id = 'a1'",
274 )
275 .unwrap();
276 assert_eq!(
277 out,
278 "SELECT * FROM (SELECT * FROM docs ORDER BY vector_distance(emb, ARRAY[0.1]) LIMIT 2) s WHERE s.id = 'a1'"
279 );
280 }
281
282 #[test]
283 fn rewrites_every_nested_occurrence() {
284 let out = try_rewrite_nested_search_using_vector(
285 "SELECT a.id FROM (SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2)) a \
286 JOIN (SEARCH docs USING VECTOR(emb, ARRAY[0.9], 3)) b ON a.id = b.id",
287 )
288 .unwrap();
289 assert_eq!(out.matches("SELECT * FROM docs ORDER BY").count(), 2);
290 assert!(!out.to_uppercase().contains("SEARCH"));
291 }
292
293 #[test]
294 fn nested_rewrite_ignores_search_inside_string_literal() {
295 assert!(
296 try_rewrite_nested_search_using_vector(
297 "SELECT * FROM docs WHERE title = '(SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2))'",
298 )
299 .is_none()
300 );
301 }
302
303 #[test]
304 fn rewrites_a_quoted_collection_name() {
305 let out = try_rewrite_search_using_vector(
306 "SEARCH \"MixedCase\" USING VECTOR(embedding, ARRAY[0.1, 0.3], 2)",
307 )
308 .unwrap();
309 assert_eq!(
310 out,
311 "SELECT * FROM \"MixedCase\" ORDER BY vector_distance(embedding, ARRAY[0.1, 0.3]) LIMIT 2",
312 "the quotes must survive the rewrite, or a name that needs them stops resolving"
313 );
314 }
315
316 #[test]
317 fn rewrites_a_quoted_collection_name_in_subquery_position() {
318 let out = try_rewrite_nested_search_using_vector(
319 "SELECT id FROM (SEARCH \"MixedCase\" USING VECTOR(embedding, ARRAY[0.1], 2)) s",
320 )
321 .unwrap();
322 assert_eq!(
323 out,
324 "SELECT id FROM (SELECT * FROM \"MixedCase\" ORDER BY vector_distance(embedding, ARRAY[0.1]) LIMIT 2) s"
325 );
326 }
327
328 #[test]
329 fn quoted_collection_name_keeps_its_escaped_quotes() {
330 let out = try_rewrite_search_using_vector(
331 "SEARCH \"od\"\"d\" USING VECTOR(embedding, ARRAY[0.1], 1)",
332 )
333 .unwrap();
334 assert!(
335 out.starts_with("SELECT * FROM \"od\"\"d\" ORDER BY"),
336 "got: {out}"
337 );
338 }
339
340 #[test]
341 fn rejects_an_unterminated_or_empty_quoted_collection_name() {
342 assert!(
343 try_rewrite_search_using_vector("SEARCH \"unterminated USING VECTOR(e, ARRAY[0.1], 1)")
344 .is_none()
345 );
346 assert!(
347 try_rewrite_search_using_vector("SEARCH \"\" USING VECTOR(e, ARRAY[0.1], 1)").is_none()
348 );
349 }
350
351 #[test]
352 fn nested_rewrite_survives_multibyte_text_after_an_open_paren() {
353 assert!(
356 try_rewrite_nested_search_using_vector(
357 "INSERT INTO t (name) VALUES ('日本語') /* SEARCH */",
358 )
359 .is_none()
360 );
361 assert!(
362 try_rewrite_nested_search_using_vector(
363 "SELECT * FROM t WHERE tag IN ('🙂😀', 'SEARCH')"
364 )
365 .is_none()
366 );
367 }
368
369 #[test]
370 fn nested_rewrite_returns_none_for_plain_subquery() {
371 assert!(
372 try_rewrite_nested_search_using_vector("SELECT * FROM (SELECT * FROM docs) s")
373 .is_none()
374 );
375 }
376
377 #[test]
378 fn nested_rewrite_returns_none_for_fusion_subquery() {
379 assert!(
380 try_rewrite_nested_search_using_vector(
381 "SELECT * FROM (SEARCH docs USING FUSION(ARRAY[0.5])) s"
382 )
383 .is_none()
384 );
385 }
386
387 #[test]
388 fn top_level_rewrite_rejects_trailing_clause() {
389 assert!(
392 try_rewrite_search_using_vector(
393 "SEARCH t USING VECTOR(emb, ARRAY[1.0], 3) WHERE x = 1"
394 )
395 .is_none()
396 );
397 }
398
399 #[test]
400 fn handles_trailing_semicolon() {
401 let out =
402 try_rewrite_search_using_vector("SEARCH t USING VECTOR(emb, ARRAY[1.0], 3);").unwrap();
403 assert!(
404 out.starts_with("SELECT * FROM t ORDER BY vector_distance(emb, ARRAY[1.0]) LIMIT 3")
405 );
406 }
407}