Skip to main content

nodedb_sql/parser/preprocess/
pipeline.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! SQL pre-processing orchestrator: rewrite NodeDB-specific syntax into
4//! standard SQL before handing to sqlparser-rs.
5//!
6//! Handles:
7//! - `UPSERT INTO coll (cols) VALUES (vals)` → `INSERT INTO ...` + upsert flag
8//! - `INSERT INTO coll { key: 'val', ... }` → `INSERT INTO coll (key) VALUES ('val')`
9//! - `UPSERT INTO coll { ... }` → both rewrites combined
10//! - `expr <-> expr` → `vector_distance(expr, expr)`
11//! - `{ key: val }` in function args → JSON string literal
12
13use super::function_args::rewrite_object_literal_args;
14use super::lex::{first_sql_word, has_brace_outside_literals, has_operator_outside_literals};
15use super::object_literal_stmt::try_rewrite_object_literal;
16use super::search_vector::{
17    try_rewrite_nested_search_using_vector, try_rewrite_search_using_vector,
18};
19use super::temporal::extract as extract_temporal;
20use super::vector_ops::{
21    rewrite_arrow_distance, rewrite_cosine_distance, rewrite_neg_inner_product,
22};
23use crate::error::SqlError;
24use crate::temporal::TemporalScope;
25
26/// Result of pre-processing a SQL string.
27#[derive(Debug)]
28pub struct PreprocessedSql {
29    /// The rewritten SQL (standard SQL that sqlparser can handle).
30    pub sql: String,
31    /// Whether the original statement was UPSERT (not INSERT).
32    pub is_upsert: bool,
33    /// Bitemporal qualifier extracted from `FOR SYSTEM_TIME` /
34    /// `FOR VALID_TIME` / `__system_as_of__(...)`. Default when none
35    /// was present.
36    pub temporal: TemporalScope,
37}
38
39/// Pre-process a SQL string, rewriting NodeDB-specific syntax.
40///
41/// Returns `Ok(None)` if no rewriting was needed. Temporal clause parse
42/// errors bubble up as `SqlError::Parse` so they surface to the caller
43/// identically to sqlparser errors.
44pub fn preprocess(sql: &str) -> Result<Option<PreprocessedSql>, SqlError> {
45    let trimmed = sql.trim();
46
47    // Extract temporal clauses first — they can appear in both SELECT and
48    // INSERT...SELECT, and stripping them before the UPSERT/object-literal
49    // rewrites keeps those rewriters pattern-free of NodeDB extensions.
50    let (temporal_sql, temporal) =
51        match extract_temporal(trimmed).map_err(|e| SqlError::Parse { detail: e.0 })? {
52            Some(ex) => (ex.sql, ex.temporal),
53            None => (trimmed.to_string(), TemporalScope::default()),
54        };
55    let any_temporal = temporal != TemporalScope::default();
56
57    // Rewrite `SEARCH <coll> USING VECTOR(...)` to canonical
58    // `SELECT * FROM <coll> ORDER BY vector_distance(...) LIMIT k` before any
59    // first-word dispatch — the rewritten form re-enters the rest of the
60    // 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.
64    let (temporal_sql, search_vector_rewritten) =
65        match try_rewrite_search_using_vector(&temporal_sql) {
66            Some(rewritten) => (rewritten, true),
67            None => match try_rewrite_nested_search_using_vector(&temporal_sql) {
68                Some(rewritten) => (rewritten, true),
69                None => (temporal_sql, false),
70            },
71        };
72
73    let first_word = first_sql_word(&temporal_sql)
74        .map(|w| w.to_uppercase())
75        .unwrap_or_default();
76    let is_upsert = first_word == "UPSERT";
77
78    if is_upsert {
79        let rewritten = format!("INSERT INTO {}", &temporal_sql["UPSERT INTO ".len()..]);
80        // `?`: a malformed object literal — or a clause the literal form cannot
81        // carry — must fail the statement here rather than fall through to be
82        // reparsed as something else, which would let the error disappear.
83        if let Some(result) = try_rewrite_object_literal(&rewritten)? {
84            return Ok(Some(PreprocessedSql {
85                sql: result,
86                is_upsert: true,
87                temporal,
88            }));
89        }
90        return Ok(Some(PreprocessedSql {
91            sql: rewritten,
92            is_upsert: true,
93            temporal,
94        }));
95    }
96
97    if first_word == "INSERT"
98        && let Some(result) = try_rewrite_object_literal(&temporal_sql)?
99    {
100        return Ok(Some(PreprocessedSql {
101            sql: result,
102            is_upsert: false,
103            temporal,
104        }));
105    }
106
107    let mut sql_buf = temporal_sql;
108    let mut any_rewrite = any_temporal || search_vector_rewritten;
109
110    if has_operator_outside_literals(&sql_buf, "<->")
111        && let Some(rewritten) = rewrite_arrow_distance(&sql_buf)
112    {
113        sql_buf = rewritten;
114        any_rewrite = true;
115    }
116
117    if has_operator_outside_literals(&sql_buf, "<=>")
118        && let Some(rewritten) = rewrite_cosine_distance(&sql_buf)
119    {
120        sql_buf = rewritten;
121        any_rewrite = true;
122    }
123
124    if has_operator_outside_literals(&sql_buf, "<#>")
125        && let Some(rewritten) = rewrite_neg_inner_product(&sql_buf)
126    {
127        sql_buf = rewritten;
128        any_rewrite = true;
129    }
130
131    if has_brace_outside_literals(&sql_buf)
132        && let Some(rewritten) = rewrite_object_literal_args(&sql_buf)
133    {
134        sql_buf = rewritten;
135        any_rewrite = true;
136    }
137
138    if any_rewrite {
139        return Ok(Some(PreprocessedSql {
140            sql: sql_buf,
141            is_upsert: false,
142            temporal,
143        }));
144    }
145
146    Ok(None)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::super::function_args::rewrite_object_literal_args;
152    use super::*;
153
154    fn pp(sql: &str) -> Option<PreprocessedSql> {
155        super::preprocess(sql).unwrap()
156    }
157
158    #[test]
159    fn passthrough_standard_sql() {
160        assert!(pp("SELECT * FROM users").is_none());
161        assert!(pp("INSERT INTO users (name) VALUES ('alice')").is_none());
162        assert!(pp("DELETE FROM users WHERE id = 1").is_none());
163    }
164
165    #[test]
166    fn upsert_rewrite() {
167        let result = pp("UPSERT INTO users (name) VALUES ('alice')").unwrap();
168        assert!(result.is_upsert);
169        assert_eq!(result.sql, "INSERT INTO users (name) VALUES ('alice')");
170    }
171
172    #[test]
173    fn object_literal_insert() {
174        let result = pp("INSERT INTO users { name: 'alice', age: 30 }").unwrap();
175        assert!(!result.is_upsert);
176        assert!(result.sql.starts_with("INSERT INTO users ("));
177        assert!(result.sql.contains("'alice'"));
178        assert!(result.sql.contains("30"));
179    }
180
181    #[test]
182    fn object_literal_upsert() {
183        let result = pp("UPSERT INTO users { name: 'bob' }").unwrap();
184        assert!(result.is_upsert);
185        assert!(result.sql.starts_with("INSERT INTO users ("));
186        assert!(result.sql.contains("'bob'"));
187    }
188
189    /// The brace form reconstructs the statement from its fields, so a clause
190    /// written after the literal cannot be carried. It is refused, by name —
191    /// the failure this guards is the earlier behavior, where the clause was
192    /// dropped and the statement succeeded as though it had never been written.
193    #[test]
194    fn a_clause_after_the_object_literal_is_refused_not_dropped() {
195        for sql in [
196            "INSERT INTO users { name: 'alice' } RETURNING *",
197            "UPSERT INTO users { name: 'alice' } RETURNING *",
198            "INSERT INTO users { name: 'alice' } ON CONFLICT (id) DO NOTHING",
199            "INSERT INTO users [{ name: 'alice' }] RETURNING *",
200        ] {
201            let error = super::preprocess(sql)
202                .expect_err("a clause the brace form cannot carry must fail the statement");
203            let detail = error.to_string();
204            assert!(
205                detail.contains("RETURNING") || detail.contains("ON CONFLICT"),
206                "the refusal must name the clause it could not carry; sql = {sql}, got {detail}"
207            );
208        }
209    }
210
211    /// …while the clean forms, and a statement terminator, still rewrite.
212    #[test]
213    fn a_trailing_semicolon_is_not_a_clause() {
214        assert!(pp("INSERT INTO users { name: 'alice' };").is_some());
215        assert!(pp("INSERT INTO users [{ name: 'alice' }];").is_some());
216    }
217
218    #[test]
219    fn batch_array_insert() {
220        let result =
221            pp("INSERT INTO users [{ name: 'alice', age: 30 }, { name: 'bob', age: 25 }]").unwrap();
222        assert!(!result.is_upsert);
223        assert!(result.sql.contains("VALUES"));
224        assert!(result.sql.contains("'alice'"));
225        assert!(result.sql.contains("'bob'"));
226        assert!(result.sql.contains("30"));
227        assert!(result.sql.contains("25"));
228        let values_part = result.sql.split("VALUES").nth(1).unwrap();
229        let row_count = values_part.matches('(').count();
230        assert_eq!(row_count, 2, "should have 2 row groups: {}", result.sql);
231    }
232
233    #[test]
234    fn batch_array_heterogeneous_keys() {
235        let result =
236            pp("INSERT INTO docs [{ id: 'a', name: 'Alice' }, { id: 'b', role: 'admin' }]")
237                .unwrap();
238        assert!(result.sql.contains("NULL"));
239        assert!(result.sql.contains("'Alice'"));
240        assert!(result.sql.contains("'admin'"));
241    }
242
243    #[test]
244    fn batch_array_upsert() {
245        let result =
246            pp("UPSERT INTO users [{ id: 'u1', name: 'a' }, { id: 'u2', name: 'b' }]").unwrap();
247        assert!(result.is_upsert);
248        assert!(result.sql.contains("VALUES"));
249    }
250
251    #[test]
252    fn arrow_distance_operator_select() {
253        let result =
254            pp("SELECT title FROM articles ORDER BY embedding <-> ARRAY[0.1, 0.2, 0.3] LIMIT 5")
255                .unwrap();
256        assert!(
257            result
258                .sql
259                .contains("vector_distance(embedding, ARRAY[0.1, 0.2, 0.3])"),
260            "got: {}",
261            result.sql
262        );
263        assert!(!result.sql.contains("<->"));
264    }
265
266    #[test]
267    fn arrow_distance_operator_where() {
268        let result = pp("SELECT * FROM docs WHERE embedding <-> ARRAY[1.0, 2.0] < 0.5").unwrap();
269        assert!(
270            result
271                .sql
272                .contains("vector_distance(embedding, ARRAY[1.0, 2.0])"),
273            "got: {}",
274            result.sql
275        );
276        // Guard against computing the left-operand start with
277        // `before.len() - left.len()`, which is off-by-one when
278        // there is whitespace between the column and `<->` — that leaves the
279        // column's leading character in the prefix, producing
280        // `WHERE evector_distance(embedding, ...)`. Lock the corrected slice.
281        assert!(
282            !result.sql.contains("evector_distance"),
283            "rewriter must not leave the column's leading char in the prefix; got: {}",
284            result.sql
285        );
286        assert!(
287            result.sql.contains("WHERE vector_distance("),
288            "rewritten WHERE clause must start `WHERE vector_distance(`; got: {}",
289            result.sql
290        );
291    }
292
293    #[test]
294    fn arrow_distance_operator_where_no_whitespace_before_op() {
295        // No whitespace between column and `<->` — the spacing-sensitive
296        // off-by-one cannot fire here; the rewriter must still produce a
297        // clean `vector_distance(embedding, ARRAY[...])` form.
298        let result = pp("SELECT id FROM docs WHERE embedding<->ARRAY[1.0, 2.0]").unwrap();
299        assert!(
300            result
301                .sql
302                .contains("vector_distance(embedding, ARRAY[1.0, 2.0])"),
303            "got: {}",
304            result.sql
305        );
306        assert!(
307            !result.sql.contains("evector_distance"),
308            "got: {}",
309            result.sql
310        );
311    }
312
313    #[test]
314    fn arrow_distance_no_match() {
315        assert!(pp("SELECT * FROM users WHERE age > 30").is_none());
316    }
317
318    #[test]
319    fn arrow_distance_with_alias() {
320        let result = pp("SELECT embedding <-> ARRAY[0.1, 0.2] AS dist FROM articles").unwrap();
321        assert!(
322            result
323                .sql
324                .contains("vector_distance(embedding, ARRAY[0.1, 0.2]) AS dist"),
325            "got: {}",
326            result.sql
327        );
328    }
329
330    #[test]
331    fn fuzzy_object_literal_in_function() {
332        let direct = rewrite_object_literal_args(
333            "SELECT * FROM articles WHERE text_match(body, 'query', { fuzzy: true })",
334        );
335        assert!(direct.is_some(), "rewrite_object_literal_args should match");
336        let rewritten = direct.unwrap();
337        assert!(
338            rewritten.contains("\"fuzzy\""),
339            "direct rewrite should contain JSON, got: {}",
340            rewritten
341        );
342
343        let result =
344            pp("SELECT * FROM articles WHERE text_match(body, 'query', { fuzzy: true })").unwrap();
345        assert!(
346            !result.sql.contains("{ fuzzy"),
347            "should not contain object literal, got: {}",
348            result.sql
349        );
350    }
351
352    #[test]
353    fn fuzzy_object_literal_with_distance() {
354        let result = pp(
355            "SELECT * FROM articles WHERE text_match(title, 'test', { fuzzy: true, distance: 2 })",
356        )
357        .unwrap();
358        assert!(result.sql.contains("\"fuzzy\""), "got: {}", result.sql);
359        assert!(result.sql.contains("\"distance\""), "got: {}", result.sql);
360    }
361
362    #[test]
363    fn object_literal_not_rewritten_outside_function() {
364        let result = pp("INSERT INTO docs { name: 'Alice' }").unwrap();
365        assert!(result.sql.contains("VALUES"), "got: {}", result.sql);
366    }
367
368    #[test]
369    fn comment_prefix_insert_routes_correctly() {
370        // A block comment before INSERT must still trigger the INSERT path.
371        let result = pp("/* hint */ INSERT INTO t VALUES ({})");
372        // Either rewrites (object literal) or passes through as INSERT — must
373        // not trigger the UPSERT path.
374        if let Some(r) = result {
375            assert!(!r.is_upsert);
376        }
377    }
378
379    #[test]
380    fn comment_prefix_upsert_routes_correctly() {
381        // A block comment before UPSERT must still trigger the UPSERT path.
382        let result = pp("/* hint */ UPSERT INTO t (name) VALUES ('a')").unwrap();
383        assert!(result.is_upsert);
384    }
385
386    #[test]
387    fn line_comment_before_insert_does_not_trigger_insert() {
388        // `-- INSERT INTO t` is a comment; the real statement is SELECT 1.
389        // It must pass through without upsert rewriting.
390        let result = pp("-- INSERT INTO t\nSELECT 1");
391        assert!(
392            result.is_none(),
393            "line-commented INSERT must pass through, got: {result:?}"
394        );
395    }
396
397    #[test]
398    fn string_literal_brace_not_rewritten_as_object_literal() {
399        // `{ foo }` is inside a string literal — must not be touched.
400        let result = pp("SELECT func('{ foo }')");
401        assert!(
402            result.is_none() || !result.unwrap().sql.contains("\"foo\""),
403            "string literal brace must not be rewritten"
404        );
405    }
406}