Skip to main content

nodedb_sql/parser/preprocess/
search_vector.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Rewrite `SEARCH <coll> USING VECTOR(<field>, ARRAY[...], <k>)` to the
4//! canonical `SELECT * FROM <coll> ORDER BY vector_distance(<field>, ARRAY[...]) LIMIT <k>`.
5//!
6//! `<field>` may be omitted (and the third arg becomes the limit). When the
7//! collection has a single declared vector column the planner resolves the
8//! field; otherwise `vector_distance` rejects the call with a typed error.
9//!
10//! The same clause is also accepted in subquery position — `FROM (SEARCH ...)
11//! s`, `IN (SELECT id FROM (SEARCH ...) s)` — so a k-NN result composes with
12//! joins and relational filters inside one statement.
13
14use 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    // A top-level `SEARCH` clause owns the whole statement; anything past the
25    // closing paren is not part of the DSL form and must not be rewritten.
26    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
34/// Rewrite every `(SEARCH <coll> USING VECTOR(...))` occurrence that sits in
35/// subquery position into `(SELECT ...)`, leaving the rest of `sql` untouched.
36///
37/// Returns `None` when no occurrence was rewritten.
38pub 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
64/// Parse a `SEARCH <coll> USING VECTOR(...)` clause at the start of `input`.
65///
66/// Returns the canonical `SELECT` and the number of bytes consumed (through
67/// the clause's closing paren).
68fn 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
85/// Match `keyword` at the start of `input` (case-insensitive, whole word) and
86/// return the remainder with leading whitespace stripped.
87fn keyword_rest<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
88    let trimmed = input.trim_start();
89    // Compare bytes: slicing `trimmed` at `keyword.len()` would panic when the
90    // input starts with a multi-byte character. An ASCII match guarantees the
91    // offset is a char boundary, so the slices below are safe.
92    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
105/// Take a collection name: a bare identifier, or a double-quoted one. The
106/// quoted form is returned with its quotes intact so the rewritten `SELECT`
107/// keeps the exact spelling the user wrote — a name that needs quoting still
108/// needs it after the rewrite.
109fn 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
124/// Take a `"quoted name"`, honouring the doubled-quote escape (`""`). Returns
125/// the quoted span verbatim and the remainder.
126fn 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        // A doubled quote is an escaped quote, not the end of the identifier.
133        if input[offset + 1..].starts_with('"') {
134            chars.next();
135            continue;
136        }
137        let end = offset + 1;
138        // An empty name ("") is not a usable collection reference.
139        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
151/// Take a parenthesized group at the start of `input`, returning its body and
152/// the number of bytes consumed (through the matching close paren). Parens
153/// inside string literals do not affect nesting depth.
154fn 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        // The clause scan runs at every `(`. Matching the keyword on bytes
354        // keeps a multi-byte literal from slicing mid-character.
355        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        // Only the DSL form is a statement; trailing SQL means the caller wrote
390        // something else and must get a parse error, not a silent rewrite.
391        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}