Skip to content

Commit 71e083a

Browse files
mkhairifarhan-syah
authored andcommitted
feat(sql): accept SEARCH ... USING VECTOR in subquery position
SEARCH was only recognised as a whole statement, so a k-NN result could not be filtered or joined in the same query, and a quoted collection name was rejected outright while a quoted column argument was accepted. The rewrite is factored into parse_search_clause, which reports the bytes it consumed; the nested pass walks literal-aware paren positions and rewrites every (SEARCH ...) it finds. take_identifier now accepts quoted names, keeping the quotes so the name still resolves. Fixing the syntax exposed two silent wrong-result bugs, both reachable today with plain SQL: apply_limit dropped an outer LIMIT over any CTE, and inline_cte dropped outer filters and projection over any non-Scan CTE body. Both are fixed for Scan and VectorSearch bodies. Closes #211 Closes #212
1 parent d545ea4 commit 71e083a

5 files changed

Lines changed: 586 additions & 28 deletions

File tree

nodedb-sql/src/parser/preprocess/pipeline.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
use super::function_args::rewrite_object_literal_args;
1414
use super::lex::{first_sql_word, has_brace_outside_literals, has_operator_outside_literals};
1515
use super::object_literal_stmt::try_rewrite_object_literal;
16-
use super::search_vector::try_rewrite_search_using_vector;
16+
use super::search_vector::{
17+
try_rewrite_nested_search_using_vector, try_rewrite_search_using_vector,
18+
};
1719
use super::temporal::extract as extract_temporal;
1820
use super::vector_ops::{
1921
rewrite_arrow_distance, rewrite_cosine_distance, rewrite_neg_inner_product,
@@ -56,10 +58,16 @@ pub fn preprocess(sql: &str) -> Result<Option<PreprocessedSql>, SqlError> {
5658
// `SELECT * FROM <coll> ORDER BY vector_distance(...) LIMIT k` before any
5759
// first-word dispatch — the rewritten form re-enters the rest of the
5860
// pipeline as a plain SELECT.
61+
//
62+
// The same clause in subquery position — `FROM (SEARCH ...) s` — is
63+
// rewritten in place so a k-NN result composes with joins and filters.
5964
let (temporal_sql, search_vector_rewritten) =
6065
match try_rewrite_search_using_vector(&temporal_sql) {
6166
Some(rewritten) => (rewritten, true),
62-
None => (temporal_sql, false),
67+
None => match try_rewrite_nested_search_using_vector(&temporal_sql) {
68+
Some(rewritten) => (rewritten, true),
69+
None => (temporal_sql, false),
70+
},
6371
};
6472

6573
let first_word = first_sql_word(&temporal_sql)

nodedb-sql/src/parser/preprocess/search_vector.rs

Lines changed: 259 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,113 @@
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

1016
const SEARCH_KEYWORD: &str = "SEARCH";
1117
const USING_KEYWORD: &str = "USING";
1218
const VECTOR_KEYWORD: &str = "VECTOR";
1319

1420
pub 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.
52112
fn 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+
64150
fn 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

77181
fn 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 =

nodedb-sql/src/planner/select/entry.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,16 @@ fn apply_limit(mut plan: SqlPlan, limit_clause: &Option<ast::LimitClause>) -> Sq
356356
}
357357
};
358358

359+
// The LIMIT belongs to the query reading the CTE, not to the CTE body, so
360+
// it lands on the outer plan — without this a derived table like
361+
// `FROM (...) s LIMIT n` comes back unbounded.
362+
if let SqlPlan::Cte { definitions, outer } = plan {
363+
return SqlPlan::Cte {
364+
definitions,
365+
outer: Box::new(apply_limit(*outer, limit_clause)),
366+
};
367+
}
368+
359369
match plan {
360370
SqlPlan::Scan {
361371
ref mut limit,

0 commit comments

Comments
 (0)