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 fn find_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
261 find_ascii_case_insensitive_from(text, needle, 0)
262}
263
264pub fn rfind_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
267 if needle.is_empty() {
268 return Some(text.len());
269 }
270 if !needle.is_ascii() || needle.len() > text.len() {
271 return None;
272 }
273
274 let haystack = text.as_bytes();
275 let needle = needle.as_bytes();
276 (0..=haystack.len() - needle.len()).rev().find(|&position| {
277 haystack[position..position + needle.len()]
278 .iter()
279 .zip(needle)
280 .all(|(left, right)| left.eq_ignore_ascii_case(right))
281 })
282}
283
284pub fn find_ascii_case_insensitive_from(text: &str, needle: &str, start: usize) -> Option<usize> {
290 if needle.is_empty() {
291 return (start <= text.len()).then_some(start);
292 }
293 if !needle.is_ascii() || start > text.len() || needle.len() > text.len() - start {
294 return None;
295 }
296
297 let haystack = text.as_bytes();
298 let needle = needle.as_bytes();
299 (start..=haystack.len() - needle.len()).find(|&position| {
300 haystack[position..position + needle.len()]
301 .iter()
302 .zip(needle)
303 .all(|(left, right)| left.eq_ignore_ascii_case(right))
304 })
305}
306
307fn is_identifier_char(character: char) -> bool {
308 character.is_alphanumeric() || character == '_'
309}
310
311fn has_word_boundaries(text: &str, position: usize, length: usize) -> bool {
312 let before_ok = position == 0
313 || !text[..position]
314 .chars()
315 .next_back()
316 .map(is_identifier_char)
317 .unwrap_or(false);
318 let after = position + length;
319 let after_ok = after >= text.len()
320 || !text[after..]
321 .chars()
322 .next()
323 .map(is_identifier_char)
324 .unwrap_or(false);
325 before_ok && after_ok
326}
327
328pub fn find_ascii_keyword(text: &str, keyword: &str) -> Option<usize> {
331 let mut start = 0;
332 while let Some(position) = find_ascii_case_insensitive_from(text, keyword, start) {
333 if has_word_boundaries(text, position, keyword.len()) {
334 return Some(position);
335 }
336 start = position + keyword.len().max(1);
337 }
338 None
339}
340
341pub fn keyword_position_outside_literals(sql: &str, kw: &str) -> Option<usize> {
349 for seg in segments(sql) {
350 if let SqlSegment::Text(text) = seg
351 && let Some(position) = find_ascii_keyword(text, kw)
352 {
353 let base = text.as_ptr() as usize - sql.as_ptr() as usize;
354 return Some(base + position);
355 }
356 }
357 None
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
367 fn plain_text_is_single_segment() {
368 let segs = segments("SELECT 1");
369 assert_eq!(segs, vec![SqlSegment::Text("SELECT 1")]);
370 }
371
372 #[test]
373 fn single_quoted_string_opaque() {
374 let segs = segments("SELECT '<->'");
375 assert_eq!(
376 segs,
377 vec![
378 SqlSegment::Text("SELECT "),
379 SqlSegment::SingleQuotedString("'<->'"),
380 ]
381 );
382 }
383
384 #[test]
385 fn quoted_ident_opaque() {
386 let segs = segments(r#"SELECT "col_<->""#);
387 assert_eq!(
388 segs,
389 vec![
390 SqlSegment::Text("SELECT "),
391 SqlSegment::QuotedIdent(r#""col_<->""#),
392 ]
393 );
394 }
395
396 #[test]
397 fn line_comment_opaque() {
398 let segs = segments("SELECT col -- has <-> in comment\nFROM t");
399 assert!(
401 segs.iter()
402 .any(|s| matches!(s, SqlSegment::LineComment(c) if c.contains("<->")))
403 );
404 assert!(
405 segs.iter()
406 .any(|s| matches!(s, SqlSegment::Text(t) if t.contains("FROM")))
407 );
408 for s in &segs {
410 if let SqlSegment::Text(t) = s {
411 assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
412 }
413 }
414 }
415
416 #[test]
417 fn block_comment_opaque() {
418 let segs = segments("SELECT /* <-> */ x");
419 assert!(
420 segs.iter()
421 .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
422 );
423 for s in &segs {
424 if let SqlSegment::Text(t) = s {
425 assert!(!t.contains("<->"), "unexpected <-> in Text: {t}");
426 }
427 }
428 }
429
430 #[test]
431 fn nested_block_comment() {
432 let segs = segments("SELECT /* /* nested */ <-> */ x");
433 assert!(
435 segs.iter()
436 .any(|s| matches!(s, SqlSegment::BlockComment(c) if c.contains("<->")))
437 );
438 for s in &segs {
439 if let SqlSegment::Text(t) = s {
440 assert!(!t.contains("<->"), "nested <-> leaked into Text: {t}");
441 }
442 }
443 }
444
445 #[test]
446 fn doubled_quote_escape_in_string() {
447 let segs = segments("SELECT 'it''s'");
448 assert_eq!(
449 segs,
450 vec![
451 SqlSegment::Text("SELECT "),
452 SqlSegment::SingleQuotedString("'it''s'"),
453 ]
454 );
455 }
456
457 #[test]
458 fn escape_string_prefix() {
459 let segs = segments("SELECT E'foo\\nbar'");
460 assert_eq!(
461 segs,
462 vec![
463 SqlSegment::Text("SELECT "),
464 SqlSegment::SingleQuotedString("E'foo\\nbar'"),
465 ]
466 );
467 }
468
469 #[test]
472 fn first_word_simple() {
473 assert_eq!(first_sql_word("SELECT 1"), Some("SELECT"));
474 }
475
476 #[test]
477 fn first_word_skips_line_comment() {
478 assert_eq!(first_sql_word("-- INSERT INTO t\nSELECT 1"), Some("SELECT"));
479 }
480
481 #[test]
482 fn first_word_skips_block_comment() {
483 assert_eq!(
484 first_sql_word("/* hint */ INSERT INTO t VALUES (1)"),
485 Some("INSERT")
486 );
487 }
488
489 #[test]
490 fn first_word_upsert_with_comment() {
491 assert_eq!(
492 first_sql_word("/* hint */ UPSERT INTO t { name: 'a' }"),
493 Some("UPSERT")
494 );
495 }
496
497 #[test]
498 fn first_word_empty() {
499 assert_eq!(first_sql_word(" "), None);
500 }
501
502 #[test]
505 fn operator_in_plain_text() {
506 assert!(has_operator_outside_literals("a <-> b", "<->"));
507 }
508
509 #[test]
510 fn operator_in_string_not_detected() {
511 assert!(!has_operator_outside_literals("SELECT '<->'", "<->"));
512 }
513
514 #[test]
515 fn operator_in_line_comment_not_detected() {
516 assert!(!has_operator_outside_literals(
517 "SELECT col -- has <-> in comment\nFROM t",
518 "<->"
519 ));
520 }
521
522 #[test]
523 fn operator_in_block_comment_not_detected() {
524 assert!(!has_operator_outside_literals("SELECT /* <-> */ x", "<->"));
525 }
526
527 #[test]
528 fn operator_in_quoted_ident_not_detected() {
529 assert!(!has_operator_outside_literals(r#"SELECT "col_<->""#, "<->"));
530 }
531
532 #[test]
535 fn brace_in_plain_text() {
536 assert!(has_brace_outside_literals("func({ foo })"));
537 }
538
539 #[test]
540 fn brace_in_string_not_detected() {
541 assert!(!has_brace_outside_literals("func('{ foo }')"));
542 }
543
544 #[test]
545 fn brace_concat_expr_not_detected() {
546 assert!(!has_brace_outside_literals("'{' || x || '}'"));
548 }
549
550 #[test]
553 fn keyword_found_in_plain_text() {
554 let sql = "SELECT * FROM t FOR SYSTEM_TIME AS OF 100";
555 assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_some());
556 }
557
558 #[test]
559 fn keyword_in_string_not_found() {
560 let sql = "SELECT * FROM t WHERE name = 'FOR SYSTEM_TIME'";
561 assert!(keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").is_none());
562 }
563
564 #[test]
565 fn keyword_position_correct() {
566 let sql = "SELECT x FOR SYSTEM_TIME AS OF 100";
567 let pos = keyword_position_outside_literals(sql, "FOR SYSTEM_TIME").unwrap();
568 let found = &sql[pos..pos + "FOR SYSTEM_TIME".len()];
570 assert_eq!(found.to_uppercase(), "FOR SYSTEM_TIME");
571 }
572
573 #[test]
574 fn keyword_position_after_unicode_text_indexes_original_sql() {
575 let sql = "SELECT ffff FROM records";
576 let pos = keyword_position_outside_literals(sql, "FROM").unwrap();
577 assert_eq!(&sql[pos..pos + "FROM".len()], "FROM");
578 }
579}