nodedb_sql/parser/preprocess/
lex.rs1#[derive(Debug, PartialEq, Eq)]
19pub enum SqlSegment<'a> {
20 Text(&'a str),
22 SingleQuotedString(&'a str),
24 QuotedIdent(&'a str),
26 LineComment(&'a str),
29 BlockComment(&'a str),
31}
32
33pub fn segments(sql: &str) -> Vec<SqlSegment<'_>> {
38 let mut out = Vec::new();
39 let bytes = sql.as_bytes();
40 let len = bytes.len();
41 let mut i = 0;
42 let mut text_start = 0;
43
44 macro_rules! flush_text {
45 () => {
46 if text_start < i {
47 out.push(SqlSegment::Text(&sql[text_start..i]));
48 }
49 };
50 }
51
52 while i < len {
53 let is_escape_prefix =
56 (bytes[i] == b'E' || bytes[i] == b'e') && i + 1 < len && bytes[i + 1] == b'\'';
57
58 if bytes[i] == b'\'' || is_escape_prefix {
59 flush_text!();
60 let start = i;
61 if is_escape_prefix {
62 i += 1; }
64 i += 1; let escape = is_escape_prefix;
66 while i < len {
67 match bytes[i] {
68 b'\\' if escape => {
69 i += 2;
71 }
72 b'\'' => {
73 i += 1;
74 if i < len && bytes[i] == b'\'' {
76 i += 1;
77 } else {
78 break;
79 }
80 }
81 _ => i += 1,
82 }
83 }
84 out.push(SqlSegment::SingleQuotedString(&sql[start..i]));
85 text_start = i;
86 continue;
87 }
88
89 if bytes[i] == b'"' {
91 flush_text!();
92 let start = i;
93 i += 1; while i < len {
95 match bytes[i] {
96 b'"' => {
97 i += 1;
98 if i < len && bytes[i] == b'"' {
100 i += 1;
101 } else {
102 break;
103 }
104 }
105 _ => i += 1,
106 }
107 }
108 out.push(SqlSegment::QuotedIdent(&sql[start..i]));
109 text_start = i;
110 continue;
111 }
112
113 if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
115 flush_text!();
116 let start = i;
117 while i < len && bytes[i] != b'\n' {
118 i += 1;
119 }
120 if i < len && bytes[i] == b'\n' {
122 i += 1;
123 }
124 out.push(SqlSegment::LineComment(&sql[start..i]));
125 text_start = i;
126 continue;
127 }
128
129 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
131 flush_text!();
132 let start = i;
133 i += 2; let mut depth: usize = 1;
135 while i < len && depth > 0 {
136 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
137 depth += 1;
138 i += 2;
139 } else if bytes[i] == b'*' && i + 1 < len && bytes[i + 1] == b'/' {
140 depth -= 1;
141 i += 2;
142 } else {
143 i += 1;
144 }
145 }
146 out.push(SqlSegment::BlockComment(&sql[start..i]));
147 text_start = i;
148 continue;
149 }
150
151 i += 1;
153 }
154
155 if text_start < len {
157 out.push(SqlSegment::Text(&sql[text_start..]));
158 }
159
160 out
161}
162
163pub fn first_sql_word(sql: &str) -> Option<&str> {
169 for seg in segments(sql) {
170 if let SqlSegment::Text(t) = seg {
171 let trimmed = t.trim_start();
172 if trimmed.is_empty() {
173 continue;
174 }
175 let end = trimmed
176 .find(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';')
177 .unwrap_or(trimmed.len());
178 if end > 0 {
179 return Some(&trimmed[..end]);
180 }
181 }
182 }
183 None
184}
185
186pub fn second_sql_word(sql: &str) -> Option<&str> {
192 let mut found_first = false;
193 for seg in segments(sql) {
194 if let SqlSegment::Text(t) = seg {
195 let mut remaining = t;
196 loop {
197 let trimmed = remaining.trim_start();
198 if trimmed.is_empty() {
199 break;
200 }
201 let end = trimmed
202 .find(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';')
203 .unwrap_or(trimmed.len());
204 if end == 0 {
205 break;
206 }
207 if !found_first {
208 found_first = true;
209 remaining = &trimmed[end..];
211 } else {
212 return Some(&trimmed[..end]);
213 }
214 }
215 }
216 }
217 None
218}
219
220pub fn has_operator_outside_literals(sql: &str, op: &str) -> bool {
224 for seg in segments(sql) {
225 if let SqlSegment::Text(t) = seg
226 && t.contains(op)
227 {
228 return true;
229 }
230 }
231 false
232}
233
234pub fn find_operator_positions(sql: &str, op: &str) -> Vec<usize> {
237 let mut positions = Vec::new();
238 for seg in segments(sql) {
239 if let SqlSegment::Text(t) = seg {
240 let base = t.as_ptr() as usize - sql.as_ptr() as usize;
242 let mut search_from = 0;
243 while let Some(rel) = t[search_from..].find(op) {
244 let abs = base + search_from + rel;
245 positions.push(abs);
246 search_from += rel + op.len();
247 }
248 }
249 }
250 positions
251}
252
253pub fn has_brace_outside_literals(sql: &str) -> bool {
255 has_operator_outside_literals(sql, "{")
256}
257
258pub use nodedb_types::{
262 find_ascii_case_insensitive, find_ascii_case_insensitive_from, rfind_ascii_case_insensitive,
263};
264
265fn is_identifier_char(character: char) -> bool {
266 character.is_alphanumeric() || character == '_'
267}
268
269fn has_word_boundaries(text: &str, position: usize, length: usize) -> bool {
270 let before_ok = position == 0
271 || !text[..position]
272 .chars()
273 .next_back()
274 .map(is_identifier_char)
275 .unwrap_or(false);
276 let after = position + length;
277 let after_ok = after >= text.len()
278 || !text[after..]
279 .chars()
280 .next()
281 .map(is_identifier_char)
282 .unwrap_or(false);
283 before_ok && after_ok
284}
285
286pub fn find_ascii_keyword(text: &str, keyword: &str) -> Option<usize> {
289 let mut start = 0;
290 while let Some(position) = find_ascii_case_insensitive_from(text, keyword, start) {
291 if has_word_boundaries(text, position, keyword.len()) {
292 return Some(position);
293 }
294 start = position + keyword.len().max(1);
295 }
296 None
297}
298
299pub fn keyword_position_outside_literals(sql: &str, kw: &str) -> Option<usize> {
307 for seg in segments(sql) {
308 if let SqlSegment::Text(text) = seg
309 && let Some(position) = find_ascii_keyword(text, kw)
310 {
311 let base = text.as_ptr() as usize - sql.as_ptr() as usize;
312 return Some(base + position);
313 }
314 }
315 None
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
325 fn plain_text_is_single_segment() {
326 let segs = segments("SELECT 1");
327 assert_eq!(segs, vec![SqlSegment::Text("SELECT 1")]);
328 }
329
330 #[test]
331 fn single_quoted_string_opaque() {
332 let segs = segments("SELECT '<->'");
333 assert_eq!(
334 segs,
335 vec![
336 SqlSegment::Text("SELECT "),
337 SqlSegment::SingleQuotedString("'<->'"),
338 ]
339 );
340 }
341
342 #[test]
343 fn quoted_ident_opaque() {
344 let segs = segments(r#"SELECT "col_<->""#);
345 assert_eq!(
346 segs,
347 vec![
348 SqlSegment::Text("SELECT "),
349 SqlSegment::QuotedIdent(r#""col_<->""#),
350 ]
351 );
352 }
353
354 #[test]
355 fn line_comment_opaque() {
356 let segs = segments("SELECT col -- has <-> in comment\nFROM t");
357 assert!(
359 segs.iter()
360 .any(|s| matches!(s, SqlSegment::LineComment(c) if c.contains("<->")))
361 );
362 assert!(
363 segs.iter()
364 .any(|s| matches!(s, SqlSegment::Text(t) if t.contains("FROM")))
365 );
366 for s in &segs {
368 if let SqlSegment::Text(t) = s {
369 assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
370 }
371 }
372 }
373
374 #[test]
375 fn block_comment_opaque() {
376 let segs = segments("SELECT /* <-> */ x");
377 assert!(
378 segs.iter()
379 .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
380 );
381 for s in &segs {
382 if let SqlSegment::Text(t) = s {
383 assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
384 }
385 }
386 }
387
388 #[test]
389 fn nested_block_comment() {
390 let segs = segments("SELECT /* /* nested */ <-> */ x");
391 assert!(
393 segs.iter()
394 .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
395 );
396 for s in &segs {
397 if let SqlSegment::Text(t) = s {
398 assert!(!t.contains("<->"), "nested <-> leaked into Text: {t}");
399 }
400 }
401 }
402
403 #[test]
404 fn doubled_quote_escape_in_string() {
405 let segs = segments("SELECT 'it''s'");
406 assert_eq!(
407 segs,
408 vec![
409 SqlSegment::Text("SELECT "),
410 SqlSegment::SingleQuotedString("'it''s'"),
411 ]
412 );
413 }
414
415 #[test]
416 fn escape_string_prefix() {
417 let segs = segments("SELECT E'foo\\nbar'");
418 assert_eq!(
419 segs,
420 vec![
421 SqlSegment::Text("SELECT "),
422 SqlSegment::SingleQuotedString("E'foo\\nbar'"),
423 ]
424 );
425 }
426
427 #[test]
430 fn first_word_simple() {
431 assert_eq!(first_sql_word("SELECT 1"), Some("SELECT"));
432 }
433
434 #[test]
435 fn first_word_skips_line_comment() {
436 assert_eq!(first_sql_word("-- INSERT INTO t\nSELECT 1"), Some("SELECT"));
437 }
438
439 #[test]
440 fn first_word_skips_block_comment() {
441 assert_eq!(
442 first_sql_word("/* hint */ INSERT INTO t VALUES (1)"),
443 Some("INSERT")
444 );
445 }
446
447 #[test]
448 fn first_word_upsert_with_comment() {
449 assert_eq!(
450 first_sql_word("/* hint */ UPSERT INTO t { name: 'a' }"),
451 Some("UPSERT")
452 );
453 }
454
455 #[test]
456 fn first_word_empty() {
457 assert_eq!(first_sql_word(" "), None);
458 }
459
460 #[test]
463 fn operator_in_plain_text() {
464 assert!(has_operator_outside_literals("a <-> b", "<->"));
465 }
466
467 #[test]
468 fn operator_in_string_not_detected() {
469 assert!(!has_operator_outside_literals("SELECT '<->'", "<->"));
470 }
471
472 #[test]
473 fn operator_in_line_comment_not_detected() {
474 assert!(!has_operator_outside_literals(
475 "SELECT col -- has <-> in comment\nFROM t",
476 "<->"
477 ));
478 }
479
480 #[test]
481 fn operator_in_block_comment_not_detected() {
482 assert!(!has_operator_outside_literals("SELECT /* <-> */ x", "<->"));
483 }
484
485 #[test]
486 fn operator_in_quoted_ident_not_detected() {
487 assert!(!has_operator_outside_literals(r#"SELECT "col_<->""#, "<->"));
488 }
489
490 #[test]
493 fn brace_in_plain_text() {
494 assert!(has_brace_outside_literals("func({ foo })"));
495 }
496
497 #[test]
498 fn brace_in_string_not_detected() {
499 assert!(!has_brace_outside_literals("func('{ foo }')"));
500 }
501
502 #[test]
503 fn brace_concat_expr_not_detected() {
504 assert!(!has_brace_outside_literals("'{' || x || '}'"));
506 }
507
508 #[test]
511 fn keyword_found_in_plain_text() {
512 let sql = "SELECT * FROM t FOR SYSTEM_TIME AS OF 100";
513 assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_some());
514 }
515
516 #[test]
517 fn keyword_in_string_not_found() {
518 let sql = "SELECT * FROM t WHERE name = 'FOR SYSTEM_TIME'";
519 assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_none());
520 }
521
522 #[test]
523 fn keyword_position_correct() {
524 let sql = "SELECT x FOR SYSTEM_TIME AS OF 100";
525 let pos = keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").unwrap();
526 let found = &sql[pos..pos + "FOR SYSTEM_TIME".len()];
528 assert_eq!(found.to_uppercase(), "FOR SYSTEM_TIME");
529 }
530
531 #[test]
532 fn keyword_position_after_unicode_text_indexes_original_sql() {
533 let sql = "SELECT ffff FROM records";
534 let pos = keyword_position_outside_literals(sql, "FROM").unwrap();
535 assert_eq!(&sql[pos..pos + "FROM".len()], "FROM");
536 }
537}