nodedb-sql 0.6.0

SQL parser, planner, and optimizer for NodeDB
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
// SPDX-License-Identifier: Apache-2.0

//! SQL pre-processing orchestrator: rewrite NodeDB-specific syntax into
//! standard SQL before handing to sqlparser-rs.
//!
//! Handles:
//! - `UPSERT INTO coll (cols) VALUES (vals)` → `INSERT INTO ...` + upsert flag
//! - `INSERT INTO coll { key: 'val', ... }` → `INSERT INTO coll (key) VALUES ('val')`
//! - `UPSERT INTO coll { ... }` → both rewrites combined
//! - `expr <-> expr` → `vector_distance(expr, expr)`
//! - `{ key: val }` in function args → JSON string literal

use super::function_args::rewrite_object_literal_args;
use super::lex::{first_sql_word, has_brace_outside_literals, has_operator_outside_literals};
use super::object_literal_stmt::try_rewrite_object_literal;
use super::search_vector::{
    try_rewrite_nested_search_using_vector, try_rewrite_search_using_vector,
};
use super::temporal::extract as extract_temporal;
use super::vector_ops::{
    rewrite_arrow_distance, rewrite_cosine_distance, rewrite_neg_inner_product,
};
use crate::error::SqlError;
use crate::temporal::TemporalScope;

/// Result of pre-processing a SQL string.
#[derive(Debug)]
pub struct PreprocessedSql {
    /// The rewritten SQL (standard SQL that sqlparser can handle).
    pub sql: String,
    /// Whether the original statement was UPSERT (not INSERT).
    pub is_upsert: bool,
    /// Bitemporal qualifier extracted from `FOR SYSTEM_TIME` /
    /// `FOR VALID_TIME` / `__system_as_of__(...)`. Default when none
    /// was present.
    pub temporal: TemporalScope,
}

/// Pre-process a SQL string, rewriting NodeDB-specific syntax.
///
/// Returns `Ok(None)` if no rewriting was needed. Temporal clause parse
/// errors bubble up as `SqlError::Parse` so they surface to the caller
/// identically to sqlparser errors.
pub fn preprocess(sql: &str) -> Result<Option<PreprocessedSql>, SqlError> {
    let trimmed = sql.trim();

    // Extract temporal clauses first — they can appear in both SELECT and
    // INSERT...SELECT, and stripping them before the UPSERT/object-literal
    // rewrites keeps those rewriters pattern-free of NodeDB extensions.
    let (temporal_sql, temporal) =
        match extract_temporal(trimmed).map_err(|e| SqlError::Parse { detail: e.0 })? {
            Some(ex) => (ex.sql, ex.temporal),
            None => (trimmed.to_string(), TemporalScope::default()),
        };
    let any_temporal = temporal != TemporalScope::default();

    // Rewrite `SEARCH <coll> USING VECTOR(...)` to canonical
    // `SELECT * FROM <coll> ORDER BY vector_distance(...) LIMIT k` before any
    // first-word dispatch — the rewritten form re-enters the rest of the
    // pipeline as a plain SELECT.
    //
    // The same clause in subquery position — `FROM (SEARCH ...) s` — is
    // rewritten in place so a k-NN result composes with joins and filters.
    let (temporal_sql, search_vector_rewritten) =
        match try_rewrite_search_using_vector(&temporal_sql) {
            Some(rewritten) => (rewritten, true),
            None => match try_rewrite_nested_search_using_vector(&temporal_sql) {
                Some(rewritten) => (rewritten, true),
                None => (temporal_sql, false),
            },
        };

    let first_word = first_sql_word(&temporal_sql)
        .map(|w| w.to_uppercase())
        .unwrap_or_default();
    let is_upsert = first_word == "UPSERT";

    if is_upsert {
        let rewritten = format!("INSERT INTO {}", &temporal_sql["UPSERT INTO ".len()..]);
        // `?`: a malformed object literal — or a clause the literal form cannot
        // carry — must fail the statement here rather than fall through to be
        // reparsed as something else, which would let the error disappear.
        if let Some(result) = try_rewrite_object_literal(&rewritten)? {
            return Ok(Some(PreprocessedSql {
                sql: result,
                is_upsert: true,
                temporal,
            }));
        }
        return Ok(Some(PreprocessedSql {
            sql: rewritten,
            is_upsert: true,
            temporal,
        }));
    }

    if first_word == "INSERT"
        && let Some(result) = try_rewrite_object_literal(&temporal_sql)?
    {
        return Ok(Some(PreprocessedSql {
            sql: result,
            is_upsert: false,
            temporal,
        }));
    }

    let mut sql_buf = temporal_sql;
    let mut any_rewrite = any_temporal || search_vector_rewritten;

    if has_operator_outside_literals(&sql_buf, "<->")
        && let Some(rewritten) = rewrite_arrow_distance(&sql_buf)
    {
        sql_buf = rewritten;
        any_rewrite = true;
    }

    if has_operator_outside_literals(&sql_buf, "<=>")
        && let Some(rewritten) = rewrite_cosine_distance(&sql_buf)
    {
        sql_buf = rewritten;
        any_rewrite = true;
    }

    if has_operator_outside_literals(&sql_buf, "<#>")
        && let Some(rewritten) = rewrite_neg_inner_product(&sql_buf)
    {
        sql_buf = rewritten;
        any_rewrite = true;
    }

    if has_brace_outside_literals(&sql_buf)
        && let Some(rewritten) = rewrite_object_literal_args(&sql_buf)
    {
        sql_buf = rewritten;
        any_rewrite = true;
    }

    if any_rewrite {
        return Ok(Some(PreprocessedSql {
            sql: sql_buf,
            is_upsert: false,
            temporal,
        }));
    }

    Ok(None)
}

#[cfg(test)]
mod tests {
    use super::super::function_args::rewrite_object_literal_args;
    use super::*;

    fn pp(sql: &str) -> Option<PreprocessedSql> {
        super::preprocess(sql).unwrap()
    }

    #[test]
    fn passthrough_standard_sql() {
        assert!(pp("SELECT * FROM users").is_none());
        assert!(pp("INSERT INTO users (name) VALUES ('alice')").is_none());
        assert!(pp("DELETE FROM users WHERE id = 1").is_none());
    }

    #[test]
    fn upsert_rewrite() {
        let result = pp("UPSERT INTO users (name) VALUES ('alice')").unwrap();
        assert!(result.is_upsert);
        assert_eq!(result.sql, "INSERT INTO users (name) VALUES ('alice')");
    }

    #[test]
    fn object_literal_insert() {
        let result = pp("INSERT INTO users { name: 'alice', age: 30 }").unwrap();
        assert!(!result.is_upsert);
        assert!(result.sql.starts_with("INSERT INTO users ("));
        assert!(result.sql.contains("'alice'"));
        assert!(result.sql.contains("30"));
    }

    #[test]
    fn object_literal_upsert() {
        let result = pp("UPSERT INTO users { name: 'bob' }").unwrap();
        assert!(result.is_upsert);
        assert!(result.sql.starts_with("INSERT INTO users ("));
        assert!(result.sql.contains("'bob'"));
    }

    /// The brace form reconstructs the statement from its fields, so a clause
    /// written after the literal cannot be carried. It is refused, by name —
    /// the failure this guards is the earlier behavior, where the clause was
    /// dropped and the statement succeeded as though it had never been written.
    #[test]
    fn a_clause_after_the_object_literal_is_refused_not_dropped() {
        for sql in [
            "INSERT INTO users { name: 'alice' } RETURNING *",
            "UPSERT INTO users { name: 'alice' } RETURNING *",
            "INSERT INTO users { name: 'alice' } ON CONFLICT (id) DO NOTHING",
            "INSERT INTO users [{ name: 'alice' }] RETURNING *",
        ] {
            let error = super::preprocess(sql)
                .expect_err("a clause the brace form cannot carry must fail the statement");
            let detail = error.to_string();
            assert!(
                detail.contains("RETURNING") || detail.contains("ON CONFLICT"),
                "the refusal must name the clause it could not carry; sql = {sql}, got {detail}"
            );
        }
    }

    /// …while the clean forms, and a statement terminator, still rewrite.
    #[test]
    fn a_trailing_semicolon_is_not_a_clause() {
        assert!(pp("INSERT INTO users { name: 'alice' };").is_some());
        assert!(pp("INSERT INTO users [{ name: 'alice' }];").is_some());
    }

    #[test]
    fn batch_array_insert() {
        let result =
            pp("INSERT INTO users [{ name: 'alice', age: 30 }, { name: 'bob', age: 25 }]").unwrap();
        assert!(!result.is_upsert);
        assert!(result.sql.contains("VALUES"));
        assert!(result.sql.contains("'alice'"));
        assert!(result.sql.contains("'bob'"));
        assert!(result.sql.contains("30"));
        assert!(result.sql.contains("25"));
        let values_part = result.sql.split("VALUES").nth(1).unwrap();
        let row_count = values_part.matches('(').count();
        assert_eq!(row_count, 2, "should have 2 row groups: {}", result.sql);
    }

    #[test]
    fn batch_array_heterogeneous_keys() {
        let result =
            pp("INSERT INTO docs [{ id: 'a', name: 'Alice' }, { id: 'b', role: 'admin' }]")
                .unwrap();
        assert!(result.sql.contains("NULL"));
        assert!(result.sql.contains("'Alice'"));
        assert!(result.sql.contains("'admin'"));
    }

    #[test]
    fn batch_array_upsert() {
        let result =
            pp("UPSERT INTO users [{ id: 'u1', name: 'a' }, { id: 'u2', name: 'b' }]").unwrap();
        assert!(result.is_upsert);
        assert!(result.sql.contains("VALUES"));
    }

    #[test]
    fn arrow_distance_operator_select() {
        let result =
            pp("SELECT title FROM articles ORDER BY embedding <-> ARRAY[0.1, 0.2, 0.3] LIMIT 5")
                .unwrap();
        assert!(
            result
                .sql
                .contains("vector_distance(embedding, ARRAY[0.1, 0.2, 0.3])"),
            "got: {}",
            result.sql
        );
        assert!(!result.sql.contains("<->"));
    }

    #[test]
    fn arrow_distance_operator_where() {
        let result = pp("SELECT * FROM docs WHERE embedding <-> ARRAY[1.0, 2.0] < 0.5").unwrap();
        assert!(
            result
                .sql
                .contains("vector_distance(embedding, ARRAY[1.0, 2.0])"),
            "got: {}",
            result.sql
        );
        // Guard against computing the left-operand start with
        // `before.len() - left.len()`, which is off-by-one when
        // there is whitespace between the column and `<->` — that leaves the
        // column's leading character in the prefix, producing
        // `WHERE evector_distance(embedding, ...)`. Lock the corrected slice.
        assert!(
            !result.sql.contains("evector_distance"),
            "rewriter must not leave the column's leading char in the prefix; got: {}",
            result.sql
        );
        assert!(
            result.sql.contains("WHERE vector_distance("),
            "rewritten WHERE clause must start `WHERE vector_distance(`; got: {}",
            result.sql
        );
    }

    #[test]
    fn arrow_distance_operator_where_no_whitespace_before_op() {
        // No whitespace between column and `<->` — the spacing-sensitive
        // off-by-one cannot fire here; the rewriter must still produce a
        // clean `vector_distance(embedding, ARRAY[...])` form.
        let result = pp("SELECT id FROM docs WHERE embedding<->ARRAY[1.0, 2.0]").unwrap();
        assert!(
            result
                .sql
                .contains("vector_distance(embedding, ARRAY[1.0, 2.0])"),
            "got: {}",
            result.sql
        );
        assert!(
            !result.sql.contains("evector_distance"),
            "got: {}",
            result.sql
        );
    }

    #[test]
    fn arrow_distance_no_match() {
        assert!(pp("SELECT * FROM users WHERE age > 30").is_none());
    }

    #[test]
    fn arrow_distance_with_alias() {
        let result = pp("SELECT embedding <-> ARRAY[0.1, 0.2] AS dist FROM articles").unwrap();
        assert!(
            result
                .sql
                .contains("vector_distance(embedding, ARRAY[0.1, 0.2]) AS dist"),
            "got: {}",
            result.sql
        );
    }

    #[test]
    fn fuzzy_object_literal_in_function() {
        let direct = rewrite_object_literal_args(
            "SELECT * FROM articles WHERE text_match(body, 'query', { fuzzy: true })",
        );
        assert!(direct.is_some(), "rewrite_object_literal_args should match");
        let rewritten = direct.unwrap();
        assert!(
            rewritten.contains("\"fuzzy\""),
            "direct rewrite should contain JSON, got: {}",
            rewritten
        );

        let result =
            pp("SELECT * FROM articles WHERE text_match(body, 'query', { fuzzy: true })").unwrap();
        assert!(
            !result.sql.contains("{ fuzzy"),
            "should not contain object literal, got: {}",
            result.sql
        );
    }

    #[test]
    fn fuzzy_object_literal_with_distance() {
        let result = pp(
            "SELECT * FROM articles WHERE text_match(title, 'test', { fuzzy: true, distance: 2 })",
        )
        .unwrap();
        assert!(result.sql.contains("\"fuzzy\""), "got: {}", result.sql);
        assert!(result.sql.contains("\"distance\""), "got: {}", result.sql);
    }

    #[test]
    fn object_literal_not_rewritten_outside_function() {
        let result = pp("INSERT INTO docs { name: 'Alice' }").unwrap();
        assert!(result.sql.contains("VALUES"), "got: {}", result.sql);
    }

    #[test]
    fn comment_prefix_insert_routes_correctly() {
        // A block comment before INSERT must still trigger the INSERT path.
        let result = pp("/* hint */ INSERT INTO t VALUES ({})");
        // Either rewrites (object literal) or passes through as INSERT — must
        // not trigger the UPSERT path.
        if let Some(r) = result {
            assert!(!r.is_upsert);
        }
    }

    #[test]
    fn comment_prefix_upsert_routes_correctly() {
        // A block comment before UPSERT must still trigger the UPSERT path.
        let result = pp("/* hint */ UPSERT INTO t (name) VALUES ('a')").unwrap();
        assert!(result.is_upsert);
    }

    #[test]
    fn line_comment_before_insert_does_not_trigger_insert() {
        // `-- INSERT INTO t` is a comment; the real statement is SELECT 1.
        // It must pass through without upsert rewriting.
        let result = pp("-- INSERT INTO t\nSELECT 1");
        assert!(
            result.is_none(),
            "line-commented INSERT must pass through, got: {result:?}"
        );
    }

    #[test]
    fn string_literal_brace_not_rewritten_as_object_literal() {
        // `{ foo }` is inside a string literal — must not be touched.
        let result = pp("SELECT func('{ foo }')");
        assert!(
            result.is_none() || !result.unwrap().sql.contains("\"foo\""),
            "string literal brace must not be rewritten"
        );
    }
}