nodedb_sql/parser/preprocess/
pipeline.rs1use 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#[derive(Debug)]
28pub struct PreprocessedSql {
29 pub sql: String,
31 pub is_upsert: bool,
33 pub temporal: TemporalScope,
37}
38
39pub fn preprocess(sql: &str) -> Result<Option<PreprocessedSql>, SqlError> {
45 let trimmed = sql.trim();
46
47 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 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 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 #[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 #[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 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 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 let result = pp("/* hint */ INSERT INTO t VALUES ({})");
372 if let Some(r) = result {
375 assert!(!r.is_upsert);
376 }
377 }
378
379 #[test]
380 fn comment_prefix_upsert_routes_correctly() {
381 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 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 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}