66//! `<field>` may be omitted (and the third arg becomes the limit). When the
77//! collection has a single declared vector column the planner resolves the
88//! 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+
14+ use super :: lex:: { find_ascii_case_insensitive, find_operator_positions} ;
915
1016const SEARCH_KEYWORD : & str = "SEARCH" ;
1117const USING_KEYWORD : & str = "USING" ;
1218const VECTOR_KEYWORD : & str = "VECTOR" ;
1319
1420pub fn try_rewrite_search_using_vector ( sql : & str ) -> Option < String > {
1521 let trimmed = sql. trim_end_matches ( |c : char | c == ';' || c. is_whitespace ( ) ) ;
16- let upper = trimmed. to_uppercase ( ) ;
17- let stripped = upper. trim_start ( ) ;
18- if !stripped. starts_with ( SEARCH_KEYWORD ) {
19- return None ;
20- }
2122 let leading = trimmed. len ( ) - trimmed. trim_start ( ) . len ( ) ;
22- let after_search = trimmed[ leading + SEARCH_KEYWORD . len ( ) ..] . trim_start ( ) ;
23- if after_search. is_empty ( ) {
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 ( ) {
2427 return None ;
2528 }
2629
27- let ( collection, rest) = take_identifier ( after_search) ?;
28- let rest = rest. trim_start ( ) ;
29- let rest_upper = rest. to_uppercase ( ) ;
30- if !rest_upper. starts_with ( USING_KEYWORD ) {
31- return None ;
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.
38+ pub fn try_rewrite_nested_search_using_vector ( sql : & str ) -> Option < String > {
39+ // Cheap gate: every other pass in the pipeline fronts its scan with a
40+ // substring check, and this one runs on every statement that is not itself
41+ // a top-level SEARCH.
42+ find_ascii_case_insensitive ( sql, SEARCH_KEYWORD ) ?;
43+
44+ let mut out = String :: new ( ) ;
45+ let mut copied = 0usize ;
46+ for open in find_operator_positions ( sql, "(" ) {
47+ if open < copied {
48+ continue ;
49+ }
50+ let after_open = open + 1 ;
51+ let inner = & sql[ after_open..] ;
52+ let leading = inner. len ( ) - inner. trim_start ( ) . len ( ) ;
53+ let Some ( ( select, consumed) ) = parse_search_clause ( & inner[ leading..] ) else {
54+ continue ;
55+ } ;
56+ out. push_str ( & sql[ copied..after_open] ) ;
57+ out. push_str ( & select) ;
58+ copied = after_open + leading + consumed;
3259 }
33- let after_using = rest[ USING_KEYWORD . len ( ) ..] . trim_start ( ) ;
34- let after_using_upper = after_using. to_uppercase ( ) ;
35- if !after_using_upper. starts_with ( VECTOR_KEYWORD ) {
60+ if copied == 0 {
3661 return None ;
3762 }
38- let after_vec = after_using[ VECTOR_KEYWORD . len ( ) ..] . trim_start ( ) ;
39- let body = strip_parentheses ( after_vec) ?;
63+ out. push_str ( & sql[ copied..] ) ;
64+ Some ( out)
65+ }
66+
67+ /// Parse a `SEARCH <coll> USING VECTOR(...)` clause at the start of `input`.
68+ ///
69+ /// Returns the canonical `SELECT` and the number of bytes consumed (through
70+ /// the clause's closing paren).
71+ fn parse_search_clause ( input : & str ) -> Option < ( String , usize ) > {
72+ let after_search = keyword_rest ( input, SEARCH_KEYWORD ) ?;
73+ let ( collection, rest) = take_identifier ( after_search. trim_start ( ) ) ?;
74+ let after_using = keyword_rest ( rest, USING_KEYWORD ) ?;
75+ let after_vector = keyword_rest ( after_using, VECTOR_KEYWORD ) ?;
76+ let ( body, consumed_args) = take_parenthesized ( after_vector) ?;
4077 let ( field, vector_expr, limit) = split_vector_args ( body) ?;
4178
42- let trailing = sql[ leading + ( trimmed. len ( ) - leading) ..] . to_string ( ) ;
4379 let order_by = match field {
4480 Some ( name) => format ! ( "vector_distance({name}, {vector_expr})" ) ,
4581 None => format ! ( "vector_distance({vector_expr})" ) ,
4682 } ;
47- Some ( format ! (
48- "SELECT * FROM {collection} ORDER BY {order_by} LIMIT {limit}{trailing}"
49- ) )
83+ let select = format ! ( "SELECT * FROM {collection} ORDER BY {order_by} LIMIT {limit}" ) ;
84+ let consumed = input . len ( ) - after_vector . len ( ) + consumed_args ;
85+ Some ( ( select , consumed ) )
5086}
5187
88+ /// Match `keyword` at the start of `input` (case-insensitive, whole word) and
89+ /// return the remainder with leading whitespace stripped.
90+ fn keyword_rest < ' a > ( input : & ' a str , keyword : & str ) -> Option < & ' a str > {
91+ let trimmed = input. trim_start ( ) ;
92+ // Compare bytes: slicing `trimmed` at `keyword.len()` would panic when the
93+ // input starts with a multi-byte character. An ASCII match guarantees the
94+ // offset is a char boundary, so the slices below are safe.
95+ let bytes = trimmed. as_bytes ( ) ;
96+ if bytes. len ( ) < keyword. len ( )
97+ || !bytes[ ..keyword. len ( ) ] . eq_ignore_ascii_case ( keyword. as_bytes ( ) )
98+ {
99+ return None ;
100+ }
101+ let rest = & trimmed[ keyword. len ( ) ..] ;
102+ if rest. chars ( ) . next ( ) . is_some_and ( is_ident_char) {
103+ return None ;
104+ }
105+ Some ( rest. trim_start ( ) )
106+ }
107+
108+ /// Take a collection name: a bare identifier, or a double-quoted one. The
109+ /// quoted form is returned with its quotes intact so the rewritten `SELECT`
110+ /// keeps the exact spelling the user wrote — a name that needs quoting still
111+ /// needs it after the rewrite.
52112fn take_identifier ( input : & str ) -> Option < ( & str , & str ) > {
113+ if input. starts_with ( '"' ) {
114+ return take_quoted_identifier ( input) ;
115+ }
53116 let end = input
54117 . char_indices ( )
55118 . find ( |( _, c) | !is_ident_char ( * c) )
@@ -61,17 +124,58 @@ fn take_identifier(input: &str) -> Option<(&str, &str)> {
61124 Some ( ( & input[ ..end] , & input[ end..] ) )
62125}
63126
127+ /// Take a `"quoted name"`, honouring the doubled-quote escape (`""`). Returns
128+ /// the quoted span verbatim and the remainder.
129+ fn take_quoted_identifier ( input : & str ) -> Option < ( & str , & str ) > {
130+ let mut chars = input. char_indices ( ) . skip ( 1 ) ;
131+ while let Some ( ( offset, c) ) = chars. next ( ) {
132+ if c != '"' {
133+ continue ;
134+ }
135+ // A doubled quote is an escaped quote, not the end of the identifier.
136+ if input[ offset + 1 ..] . starts_with ( '"' ) {
137+ chars. next ( ) ;
138+ continue ;
139+ }
140+ let end = offset + 1 ;
141+ // An empty name ("") is not a usable collection reference.
142+ if end == 2 {
143+ return None ;
144+ }
145+ return Some ( ( & input[ ..end] , & input[ end..] ) ) ;
146+ }
147+ None
148+ }
149+
64150fn is_ident_char ( c : char ) -> bool {
65151 c. is_ascii_alphanumeric ( ) || c == '_'
66152}
67153
68- fn strip_parentheses ( input : & str ) -> Option < & str > {
69- let trimmed = input. trim ( ) ;
70- let bytes = trimmed. as_bytes ( ) ;
71- if bytes. first ( ) != Some ( & b'(' ) || bytes. last ( ) != Some ( & b')' ) {
154+ /// Take a parenthesized group at the start of `input`, returning its body and
155+ /// the number of bytes consumed (through the matching close paren). Parens
156+ /// inside string literals do not affect nesting depth.
157+ fn take_parenthesized ( input : & str ) -> Option < ( & str , usize ) > {
158+ if !input. starts_with ( '(' ) {
72159 return None ;
73160 }
74- Some ( trimmed[ 1 ..trimmed. len ( ) - 1 ] . trim ( ) )
161+ let mut depth = 0i32 ;
162+ let mut in_single = false ;
163+ let mut in_double = false ;
164+ for ( offset, c) in input. char_indices ( ) {
165+ match c {
166+ '\'' if !in_double => in_single = !in_single,
167+ '"' if !in_single => in_double = !in_double,
168+ '(' if !in_single && !in_double => depth += 1 ,
169+ ')' if !in_single && !in_double => {
170+ depth -= 1 ;
171+ if depth == 0 {
172+ return Some ( ( input[ 1 ..offset] . trim ( ) , offset + 1 ) ) ;
173+ }
174+ }
175+ _ => { }
176+ }
177+ }
178+ None
75179}
76180
77181fn split_vector_args ( body : & str ) -> Option < ( Option < String > , String , String ) > {
@@ -157,6 +261,135 @@ mod tests {
157261 assert ! ( try_rewrite_search_using_vector( "SEARCH c USING FUSION(ARRAY[0.5])" ) . is_none( ) ) ;
158262 }
159263
264+ #[ test]
265+ fn rewrites_search_in_derived_table_position ( ) {
266+ let out = try_rewrite_nested_search_using_vector (
267+ "SELECT * FROM (SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2)) s WHERE s.id = 'a1'" ,
268+ )
269+ . unwrap ( ) ;
270+ assert_eq ! (
271+ out,
272+ "SELECT * FROM (SELECT * FROM docs ORDER BY vector_distance(emb, ARRAY[0.1]) LIMIT 2) s WHERE s.id = 'a1'"
273+ ) ;
274+ }
275+
276+ #[ test]
277+ fn rewrites_every_nested_occurrence ( ) {
278+ let out = try_rewrite_nested_search_using_vector (
279+ "SELECT a.id FROM (SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2)) a \
280+ JOIN (SEARCH docs USING VECTOR(emb, ARRAY[0.9], 3)) b ON a.id = b.id",
281+ )
282+ . unwrap ( ) ;
283+ assert_eq ! ( out. matches( "SELECT * FROM docs ORDER BY" ) . count( ) , 2 ) ;
284+ assert ! ( !out. to_uppercase( ) . contains( "SEARCH" ) ) ;
285+ }
286+
287+ #[ test]
288+ fn nested_rewrite_ignores_search_inside_string_literal ( ) {
289+ assert ! (
290+ try_rewrite_nested_search_using_vector(
291+ "SELECT * FROM docs WHERE title = '(SEARCH docs USING VECTOR(emb, ARRAY[0.1], 2))'" ,
292+ )
293+ . is_none( )
294+ ) ;
295+ }
296+
297+ #[ test]
298+ fn rewrites_a_quoted_collection_name ( ) {
299+ let out = try_rewrite_search_using_vector (
300+ "SEARCH \" MixedCase\" USING VECTOR(embedding, ARRAY[0.1, 0.3], 2)" ,
301+ )
302+ . unwrap ( ) ;
303+ assert_eq ! (
304+ out,
305+ "SELECT * FROM \" MixedCase\" ORDER BY vector_distance(embedding, ARRAY[0.1, 0.3]) LIMIT 2" ,
306+ "the quotes must survive the rewrite, or a name that needs them stops resolving"
307+ ) ;
308+ }
309+
310+ #[ test]
311+ fn rewrites_a_quoted_collection_name_in_subquery_position ( ) {
312+ let out = try_rewrite_nested_search_using_vector (
313+ "SELECT id FROM (SEARCH \" MixedCase\" USING VECTOR(embedding, ARRAY[0.1], 2)) s" ,
314+ )
315+ . unwrap ( ) ;
316+ assert_eq ! (
317+ out,
318+ "SELECT id FROM (SELECT * FROM \" MixedCase\" ORDER BY vector_distance(embedding, ARRAY[0.1]) LIMIT 2) s"
319+ ) ;
320+ }
321+
322+ #[ test]
323+ fn quoted_collection_name_keeps_its_escaped_quotes ( ) {
324+ let out = try_rewrite_search_using_vector (
325+ "SEARCH \" od\" \" d\" USING VECTOR(embedding, ARRAY[0.1], 1)" ,
326+ )
327+ . unwrap ( ) ;
328+ assert ! (
329+ out. starts_with( "SELECT * FROM \" od\" \" d\" ORDER BY" ) ,
330+ "got: {out}"
331+ ) ;
332+ }
333+
334+ #[ test]
335+ fn rejects_an_unterminated_or_empty_quoted_collection_name ( ) {
336+ assert ! (
337+ try_rewrite_search_using_vector( "SEARCH \" unterminated USING VECTOR(e, ARRAY[0.1], 1)" )
338+ . is_none( )
339+ ) ;
340+ assert ! (
341+ try_rewrite_search_using_vector( "SEARCH \" \" USING VECTOR(e, ARRAY[0.1], 1)" ) . is_none( )
342+ ) ;
343+ }
344+
345+ #[ test]
346+ fn nested_rewrite_survives_multibyte_text_after_an_open_paren ( ) {
347+ // The clause scan runs at every `(`. Matching the keyword on bytes
348+ // keeps a multi-byte literal from slicing mid-character.
349+ assert ! (
350+ try_rewrite_nested_search_using_vector(
351+ "INSERT INTO t (name) VALUES ('日本語') /* SEARCH */" ,
352+ )
353+ . is_none( )
354+ ) ;
355+ assert ! (
356+ try_rewrite_nested_search_using_vector(
357+ "SELECT * FROM t WHERE tag IN ('🙂😀', 'SEARCH')"
358+ )
359+ . is_none( )
360+ ) ;
361+ }
362+
363+ #[ test]
364+ fn nested_rewrite_returns_none_for_plain_subquery ( ) {
365+ assert ! (
366+ try_rewrite_nested_search_using_vector( "SELECT * FROM (SELECT * FROM docs) s" )
367+ . is_none( )
368+ ) ;
369+ }
370+
371+ #[ test]
372+ fn nested_rewrite_returns_none_for_fusion_subquery ( ) {
373+ assert ! (
374+ try_rewrite_nested_search_using_vector(
375+ "SELECT * FROM (SEARCH docs USING FUSION(ARRAY[0.5])) s"
376+ )
377+ . is_none( )
378+ ) ;
379+ }
380+
381+ #[ test]
382+ fn top_level_rewrite_rejects_trailing_clause ( ) {
383+ // Only the DSL form is a statement; trailing SQL means the caller wrote
384+ // something else and must get a parse error, not a silent rewrite.
385+ assert ! (
386+ try_rewrite_search_using_vector(
387+ "SEARCH t USING VECTOR(emb, ARRAY[1.0], 3) WHERE x = 1"
388+ )
389+ . is_none( )
390+ ) ;
391+ }
392+
160393 #[ test]
161394 fn handles_trailing_semicolon ( ) {
162395 let out =
0 commit comments