1use regex::Regex;
12use std::borrow::Cow;
13use std::sync::LazyLock;
14
15static IN_LIST_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"(?i)IN\s*\(\s*\?(?:\s*,\s*\?)*\s*\)").expect("static regex"));
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SqlNormalized {
21 pub template: String,
22 pub params: Vec<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26enum State {
27 Normal,
28 InString,
29 InNumber,
30 InDoubleQuote,
32 InBacktick,
34 InDollarQuote,
36}
37
38struct Tokenizer<'a> {
40 query: &'a str,
41 bytes: &'a [u8],
42 template: String,
43 params: Vec<String>,
44 i: usize,
45 state: State,
46 current_value: String,
47 seen_dot: bool,
48 has_in_list: bool,
49 normal_start: usize,
50 value_start: usize,
52 dollar_tag: Vec<u8>,
54}
55
56const MAX_QUERY_LEN: usize = 65_536;
59
60#[must_use]
62pub fn normalize_sql(query: &str) -> SqlNormalized {
63 let query = if query.len() > MAX_QUERY_LEN {
65 &query[..query.floor_char_boundary(MAX_QUERY_LEN)]
66 } else {
67 query
68 };
69 let mut t = Tokenizer {
70 query,
71 bytes: query.as_bytes(),
72 template: String::with_capacity(query.len()),
73 params: Vec::with_capacity(4),
74 i: 0,
75 state: State::Normal,
76 current_value: String::new(),
77 seen_dot: false,
78 has_in_list: false,
79 normal_start: 0,
80 value_start: 0,
81 dollar_tag: Vec::new(),
82 };
83
84 while t.i < t.bytes.len() {
85 match t.state {
86 State::Normal => step_normal(&mut t),
87 State::InString => step_in_string(&mut t),
88 State::InNumber => step_in_number(&mut t),
89 State::InDoubleQuote => step_in_double_quote(&mut t),
90 State::InBacktick => step_in_backtick(&mut t),
91 State::InDollarQuote => step_in_dollar_quote(&mut t),
92 }
93 }
94
95 flush_pending(&mut t);
96 collapse_in_lists(t.template, t.has_in_list, t.params)
97}
98
99fn checked_query_slice(query: &str, start: usize, end: usize) -> &str {
109 debug_assert!(
110 query.is_char_boundary(start) && query.is_char_boundary(end),
111 "SQL tokenizer query slice [{start}..{end}) is off a char boundary; \
112 bounds must anchor on ASCII delimiters"
113 );
114 &query[start..end]
115}
116
117fn step_normal(t: &mut Tokenizer<'_>) {
118 let b = t.bytes[t.i];
119 if b == b'\'' {
120 flush_normal_run(t);
121 t.state = State::InString;
122 t.current_value.clear();
123 t.value_start = t.i + 1; } else if b == b'"' {
125 t.state = State::InDoubleQuote;
127 t.i += 1;
128 return;
129 } else if b == b'`' {
130 t.state = State::InBacktick;
132 t.i += 1;
133 return;
134 } else if b == b'$' && is_dollar_param(t.i, t.bytes) {
135 flush_normal_run(t);
142 let mut end = t.i + 1; while end < t.bytes.len() && t.bytes[end].is_ascii_digit() {
144 end += 1;
145 }
146 t.template.push_str("$?");
147 t.i = end;
148 t.normal_start = t.i;
149 return;
150 } else if b == b'$' && is_dollar_quote_start(t.i, t.bytes) {
151 let tag = extract_dollar_tag(t.i, t.bytes);
153 flush_normal_run(t);
154 let tag_len = tag.len();
155 t.dollar_tag = tag;
156 t.state = State::InDollarQuote;
157 t.current_value.clear();
158 t.i += tag_len;
159 t.value_start = t.i; return;
161 } else if b.is_ascii_digit() && !is_identifier_byte_before(t.i, t.bytes) {
162 flush_normal_run(t);
163 t.state = State::InNumber;
164 t.seen_dot = false;
165 t.current_value.clear();
166 t.current_value.push(b as char);
167 } else if !t.has_in_list {
168 t.has_in_list = is_in_keyword(t.i, t.bytes);
169 }
170 t.i += 1;
171}
172
173fn step_in_string(t: &mut Tokenizer<'_>) {
174 let b = t.bytes[t.i];
175 if b == b'\'' {
176 if t.i + 1 < t.bytes.len() && t.bytes[t.i + 1] == b'\'' {
177 t.current_value
179 .push_str(checked_query_slice(t.query, t.value_start, t.i));
180 t.current_value.push('\'');
181 t.i += 2;
182 t.value_start = t.i;
183 } else {
184 t.current_value
186 .push_str(checked_query_slice(t.query, t.value_start, t.i));
187 t.params.push(std::mem::take(&mut t.current_value));
188 t.template.push('?');
189 t.state = State::Normal;
190 t.i += 1;
191 t.normal_start = t.i;
192 }
193 } else {
194 t.i += 1;
195 }
196}
197
198fn step_in_number(t: &mut Tokenizer<'_>) {
199 let b = t.bytes[t.i];
200 if b.is_ascii_digit() {
201 t.current_value.push(b as char);
202 t.i += 1;
203 } else if b == b'.' && !t.seen_dot {
204 t.seen_dot = true;
205 t.current_value.push('.');
206 t.i += 1;
207 } else {
208 t.params.push(std::mem::take(&mut t.current_value));
209 t.template.push('?');
210 t.state = State::Normal;
211 t.normal_start = t.i;
212 }
213}
214
215fn step_in_double_quote(t: &mut Tokenizer<'_>) {
216 if t.bytes[t.i] == b'"' {
217 t.state = State::Normal;
218 }
219 t.i += 1;
220}
221
222fn step_in_backtick(t: &mut Tokenizer<'_>) {
223 if t.bytes[t.i] == b'`' {
226 t.state = State::Normal;
227 }
228 t.i += 1;
229}
230
231fn step_in_dollar_quote(t: &mut Tokenizer<'_>) {
232 let remaining = &t.bytes[t.i..];
234 if remaining.starts_with(&t.dollar_tag) {
235 t.current_value
237 .push_str(checked_query_slice(t.query, t.value_start, t.i));
238 t.params.push(std::mem::take(&mut t.current_value));
239 t.template.push('?');
240 t.i += t.dollar_tag.len();
241 t.state = State::Normal;
242 t.normal_start = t.i;
243 } else {
244 t.i += 1;
245 }
246}
247
248fn is_dollar_param(i: usize, bytes: &[u8]) -> bool {
257 debug_assert_eq!(bytes[i], b'$');
258 let next = i + 1;
259 next < bytes.len() && bytes[next].is_ascii_digit()
260}
261
262fn is_dollar_quote_start(i: usize, bytes: &[u8]) -> bool {
264 if i >= bytes.len() || bytes[i] != b'$' {
265 return false;
266 }
267 if i + 1 < bytes.len() && bytes[i + 1] == b'$' {
269 return true;
270 }
271 let mut j = i + 1;
273 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
274 j += 1;
275 }
276 j > i + 1 && j < bytes.len() && bytes[j] == b'$'
277}
278
279fn extract_dollar_tag(i: usize, bytes: &[u8]) -> Vec<u8> {
286 if i + 1 < bytes.len() && bytes[i + 1] == b'$' {
288 return vec![b'$', b'$'];
289 }
290 let mut j = i + 1;
292 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
293 j += 1;
294 }
295 bytes[i..=j].to_vec()
296}
297
298fn flush_normal_run(t: &mut Tokenizer<'_>) {
299 if t.i > t.normal_start {
300 t.template
301 .push_str(checked_query_slice(t.query, t.normal_start, t.i));
302 }
303}
304
305fn flush_pending(t: &mut Tokenizer<'_>) {
306 match t.state {
307 State::InString | State::InDollarQuote => {
308 t.current_value
310 .push_str(checked_query_slice(t.query, t.value_start, t.bytes.len()));
311 t.params.push(std::mem::take(&mut t.current_value));
312 t.template.push('?');
313 }
314 State::InNumber => {
315 t.params.push(std::mem::take(&mut t.current_value));
316 t.template.push('?');
317 }
318 State::Normal | State::InDoubleQuote | State::InBacktick => {
319 let len = t.bytes.len();
320 if len > t.normal_start {
321 t.template
322 .push_str(checked_query_slice(t.query, t.normal_start, len));
323 }
324 }
325 }
326}
327
328fn is_in_keyword(i: usize, bytes: &[u8]) -> bool {
329 let b = bytes[i];
330 (b == b'I' || b == b'i')
331 && i + 1 < bytes.len()
332 && (bytes[i + 1] == b'N' || bytes[i + 1] == b'n')
333 && (i == 0 || bytes[i - 1].is_ascii_whitespace())
334 && (i + 2 >= bytes.len() || !bytes[i + 2].is_ascii_alphanumeric())
335}
336
337fn collapse_in_lists(template: String, has_in_list: bool, params: Vec<String>) -> SqlNormalized {
338 let template = if has_in_list {
339 match IN_LIST_RE.replace_all(&template, "IN (?)") {
340 Cow::Borrowed(_) => template,
341 Cow::Owned(s) => s,
342 }
343 } else {
344 template
345 };
346 SqlNormalized { template, params }
347}
348
349fn is_identifier_byte_before(i: usize, bytes: &[u8]) -> bool {
353 if i == 0 {
354 return false;
355 }
356 let prev = bytes[i - 1];
357 prev.is_ascii_alphanumeric() || prev == b'_'
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[test]
365 fn numeric_literal() {
366 let r = normalize_sql("SELECT * FROM order_item WHERE order_id = 42");
367 assert_eq!(r.template, "SELECT * FROM order_item WHERE order_id = ?");
368 assert_eq!(r.params, vec!["42"]);
369 }
370
371 #[test]
372 fn float_literal() {
373 let r = normalize_sql("SELECT * FROM t WHERE price > 3.14");
374 assert_eq!(r.template, "SELECT * FROM t WHERE price > ?");
375 assert_eq!(r.params, vec!["3.14"]);
376 }
377
378 #[test]
379 fn string_literal() {
380 let r = normalize_sql("SELECT * FROM users WHERE name = 'Alice'");
381 assert_eq!(r.template, "SELECT * FROM users WHERE name = ?");
382 assert_eq!(r.params, vec!["Alice"]);
383 }
384
385 #[test]
386 fn uuid_in_string() {
387 let r = normalize_sql("SELECT * FROM t WHERE id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'");
388 assert_eq!(r.template, "SELECT * FROM t WHERE id = ?");
389 assert_eq!(r.params, vec!["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]);
390 }
391
392 #[test]
393 fn in_list_collapsed() {
394 let r = normalize_sql("SELECT * FROM t WHERE id IN (1, 2, 3)");
395 assert_eq!(r.template, "SELECT * FROM t WHERE id IN (?)");
396 assert_eq!(r.params, vec!["1", "2", "3"]);
397 }
398
399 #[test]
400 fn in_list_strings_collapsed() {
401 let r = normalize_sql("SELECT * FROM t WHERE name IN ('a', 'b', 'c')");
402 assert_eq!(r.template, "SELECT * FROM t WHERE name IN (?)");
403 assert_eq!(r.params, vec!["a", "b", "c"]);
404 }
405
406 #[test]
407 fn escaped_quotes() {
408 let r = normalize_sql("SELECT * FROM t WHERE name = 'O''Brien'");
409 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
410 assert_eq!(r.params, vec!["O'Brien"]);
411 }
412
413 #[test]
414 fn table_names_with_digits_preserved() {
415 let r = normalize_sql("SELECT * FROM order_item2 WHERE id = 1");
416 assert_eq!(r.template, "SELECT * FROM order_item2 WHERE id = ?");
417 assert_eq!(r.params, vec!["1"]);
418 }
419
420 #[test]
421 fn join_query() {
422 let r = normalize_sql(
423 "SELECT p.name FROM order_item p JOIN orders g ON p.order_id = g.id WHERE g.id = 42",
424 );
425 assert_eq!(
426 r.template,
427 "SELECT p.name FROM order_item p JOIN orders g ON p.order_id = g.id WHERE g.id = ?"
428 );
429 assert_eq!(r.params, vec!["42"]);
430 }
431
432 #[test]
433 fn multiple_params() {
434 let r = normalize_sql("UPDATE t SET a = 1, b = 'foo' WHERE id = 99");
435 assert_eq!(r.template, "UPDATE t SET a = ?, b = ? WHERE id = ?");
436 assert_eq!(r.params, vec!["1", "foo", "99"]);
437 }
438
439 #[test]
440 fn no_literals() {
441 let r = normalize_sql("SELECT count(*) FROM users");
442 assert_eq!(r.template, "SELECT count(*) FROM users");
443 assert!(r.params.is_empty());
444 }
445
446 #[test]
447 fn multi_dot_number_rejected() {
448 let r = normalize_sql("SELECT * FROM t WHERE x = 1.2.3");
450 assert_eq!(r.params[0], "1.2");
451 }
452
453 #[test]
454 fn unterminated_string_flushed() {
455 let r = normalize_sql("SELECT * FROM t WHERE name = 'unterminated");
456 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
457 assert_eq!(r.params, vec!["unterminated"]);
458 }
459
460 #[test]
461 fn empty_query() {
462 let r = normalize_sql("");
463 assert_eq!(r.template, "");
464 assert!(r.params.is_empty());
465 }
466
467 #[test]
468 fn number_at_start_of_query() {
469 let r = normalize_sql("42");
470 assert_eq!(r.template, "?");
471 assert_eq!(r.params, vec!["42"]);
472 }
473
474 #[test]
475 fn multi_dot_full_template() {
476 let r = normalize_sql("SELECT * FROM t WHERE x = 1.2.3");
478 assert_eq!(r.template, "SELECT * FROM t WHERE x = ?.?");
479 assert_eq!(r.params, vec!["1.2", "3"]);
480 }
481
482 #[test]
483 fn empty_string_literal() {
484 let r = normalize_sql("SELECT * FROM t WHERE name = ''");
485 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
486 assert_eq!(r.params, vec![""]);
487 }
488
489 #[test]
490 fn digit_in_string_literal() {
491 let r = normalize_sql("SELECT * FROM t WHERE code = '42'");
492 assert_eq!(r.template, "SELECT * FROM t WHERE code = ?");
493 assert_eq!(r.params, vec!["42"]);
494 }
495
496 #[test]
497 fn underscore_before_digit_preserved() {
498 let r = normalize_sql("SELECT col_1 FROM t");
500 assert_eq!(r.template, "SELECT col_1 FROM t");
501 assert!(r.params.is_empty());
502 }
503
504 #[test]
505 fn number_only_query_at_eof() {
506 let r = normalize_sql("SELECT * FROM t LIMIT 100");
508 assert_eq!(r.template, "SELECT * FROM t LIMIT ?");
509 assert_eq!(r.params, vec!["100"]);
510 }
511
512 #[test]
513 fn cow_borrowed_path_no_in_list() {
514 let r = normalize_sql("SELECT 1");
516 assert_eq!(r.template, "SELECT ?");
517 assert_eq!(r.params, vec!["1"]);
518 }
519
520 #[test]
521 fn negative_number_not_collapsed() {
522 let r = normalize_sql("SELECT * FROM t WHERE x = -5");
524 assert_eq!(r.template, "SELECT * FROM t WHERE x = -?");
525 assert_eq!(r.params, vec!["5"]);
526 }
527
528 #[test]
531 fn cte_basic() {
532 let r = normalize_sql(
533 "WITH active AS (SELECT id FROM users WHERE status = 'active') \
534 SELECT * FROM orders WHERE user_id IN (SELECT id FROM active) AND total > 100",
535 );
536 assert_eq!(
537 r.template,
538 "WITH active AS (SELECT id FROM users WHERE status = ?) \
539 SELECT * FROM orders WHERE user_id IN (SELECT id FROM active) AND total > ?"
540 );
541 assert_eq!(r.params, vec!["active", "100"]);
542 }
543
544 #[test]
545 fn cte_nested() {
546 let r = normalize_sql(
547 "WITH a AS (SELECT 1), b AS (SELECT * FROM a WHERE x = 'test') \
548 SELECT * FROM b WHERE id = 42",
549 );
550 assert_eq!(
551 r.template,
552 "WITH a AS (SELECT ?), b AS (SELECT * FROM a WHERE x = ?) \
553 SELECT * FROM b WHERE id = ?"
554 );
555 assert_eq!(r.params, vec!["1", "test", "42"]);
556 }
557
558 #[test]
561 fn double_quoted_identifier_preserved() {
562 let r = normalize_sql(r#"SELECT * FROM "MyTable" WHERE "Column" = 42"#);
563 assert_eq!(r.template, r#"SELECT * FROM "MyTable" WHERE "Column" = ?"#);
564 assert_eq!(r.params, vec!["42"]);
565 }
566
567 #[test]
568 fn double_quoted_with_digits_preserved() {
569 let r = normalize_sql(r#"SELECT * FROM "table_2" WHERE "col_3" = 'value'"#);
571 assert_eq!(r.template, r#"SELECT * FROM "table_2" WHERE "col_3" = ?"#);
572 assert_eq!(r.params, vec!["value"]);
573 }
574
575 #[test]
578 fn backtick_identifier_preserved() {
579 let r = normalize_sql("SELECT `name` FROM `users` WHERE `id` = 42");
580 assert_eq!(r.template, "SELECT `name` FROM `users` WHERE `id` = ?");
581 assert_eq!(r.params, vec!["42"]);
582 }
583
584 #[test]
585 fn backtick_identifier_with_digits_preserved() {
586 let r = normalize_sql("SELECT `col2` FROM `table_3` WHERE `id` = 'x'");
588 assert_eq!(r.template, "SELECT `col2` FROM `table_3` WHERE `id` = ?");
589 assert_eq!(r.params, vec!["x"]);
590 }
591
592 #[test]
593 fn sqlserver_bracket_identifier_survives_without_special_handling() {
594 let r = normalize_sql("SELECT [Col1] FROM [dbo].[Users] WHERE [Id] = 5");
598 assert_eq!(
599 r.template,
600 "SELECT [Col1] FROM [dbo].[Users] WHERE [Id] = ?"
601 );
602 assert_eq!(r.params, vec!["5"]);
603 }
604
605 #[test]
606 fn bracket_string_literals_are_redacted() {
607 let r = normalize_sql("SELECT * FROM t WHERE tags = ARRAY['secret', 'pii']");
611 assert_eq!(r.template, "SELECT * FROM t WHERE tags = ARRAY[?, ?]");
612 assert_eq!(r.params, vec!["secret", "pii"]);
613 }
614
615 #[test]
618 fn dollar_param_single() {
619 let r = normalize_sql("SELECT * FROM order_items WHERE order_id = $1");
620 assert_eq!(r.template, "SELECT * FROM order_items WHERE order_id = $?");
621 assert!(
622 r.params.is_empty(),
623 "positional $1 is a placeholder, not a literal"
624 );
625 }
626
627 #[test]
628 fn dollar_param_multiple() {
629 let r = normalize_sql("SELECT * FROM t WHERE a = $1 AND b = $2");
630 assert_eq!(r.template, "SELECT * FROM t WHERE a = $? AND b = $?");
631 assert!(r.params.is_empty());
632 }
633
634 #[test]
635 fn dollar_param_two_digit_index() {
636 let r = normalize_sql("SELECT * FROM t WHERE id = $12");
637 assert_eq!(r.template, "SELECT * FROM t WHERE id = $?");
638 assert!(r.params.is_empty());
639 }
640
641 #[test]
642 fn dollar_param_does_not_break_dollar_quote() {
643 let r = normalize_sql("SELECT $$hello$$");
646 assert_eq!(r.template, "SELECT ?");
647 assert_eq!(r.params, vec!["hello"]);
648 }
649
650 #[test]
653 fn dollar_quote_basic() {
654 let r = normalize_sql("SELECT $$hello world$$ AS greeting");
655 assert_eq!(r.template, "SELECT ? AS greeting");
656 assert_eq!(r.params, vec!["hello world"]);
657 }
658
659 #[test]
660 fn dollar_quote_tagged() {
661 let r = normalize_sql("SELECT $tag$some body$tag$ AS body");
662 assert_eq!(r.template, "SELECT ? AS body");
663 assert_eq!(r.params, vec!["some body"]);
664 }
665
666 #[test]
667 fn dollar_quote_in_function() {
668 let r = normalize_sql(
669 "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN RAISE NOTICE 'hi'; END; $$ LANGUAGE plpgsql",
670 );
671 assert_eq!(
672 r.template,
673 "CREATE FUNCTION foo() RETURNS void AS ? LANGUAGE plpgsql"
674 );
675 }
676
677 #[test]
680 fn call_with_params() {
681 let r = normalize_sql("CALL process_order(42, 'rush', NOW())");
682 assert_eq!(r.template, "CALL process_order(?, ?, NOW())");
683 assert_eq!(r.params, vec!["42", "rush"]);
684 }
685
686 #[test]
687 fn call_with_interval() {
688 let r = normalize_sql("CALL schedule_task(1, INTERVAL '2 days')");
689 assert_eq!(r.template, "CALL schedule_task(?, INTERVAL ?)");
690 assert_eq!(r.params, vec!["1", "2 days"]);
691 }
692
693 #[test]
696 fn utf8_in_string_literal() {
697 let r = normalize_sql("SELECT * FROM t WHERE name = 'caf\u{00e9}'");
698 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
699 assert_eq!(r.params, vec!["caf\u{00e9}"]);
700 }
701
702 #[test]
703 fn utf8_emoji_in_string_literal() {
704 let r = normalize_sql("INSERT INTO t (msg) VALUES ('\u{1F600} hello')");
705 assert_eq!(r.template, "INSERT INTO t (msg) VALUES (?)");
706 assert_eq!(r.params, vec!["\u{1F600} hello"]);
707 }
708
709 #[test]
710 fn utf8_cjk_in_string_literal() {
711 let r = normalize_sql("SELECT * FROM t WHERE name = '\u{4F60}\u{597D}'");
712 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
713 assert_eq!(r.params, vec!["\u{4F60}\u{597D}"]);
714 }
715
716 #[test]
717 fn utf8_in_dollar_quote() {
718 let r = normalize_sql("SELECT $$caf\u{00e9} au lait$$ AS drink");
719 assert_eq!(r.template, "SELECT ? AS drink");
720 assert_eq!(r.params, vec!["caf\u{00e9} au lait"]);
721 }
722
723 #[test]
724 fn utf8_with_escaped_quotes() {
725 let r = normalize_sql("SELECT * FROM t WHERE name = 'caf\u{00e9} d''or'");
726 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
727 assert_eq!(r.params, vec!["caf\u{00e9} d'or"]);
728 }
729
730 #[test]
733 fn checked_query_slice_returns_substring_on_char_boundaries() {
734 assert_eq!(checked_query_slice("x\u{00e9}y", 1, 3), "\u{00e9}");
737 }
738
739 #[cfg(debug_assertions)]
740 #[test]
741 #[should_panic(expected = "must anchor on ASCII delimiters")]
742 fn checked_query_slice_rejects_non_char_boundary() {
743 let _ = checked_query_slice("x\u{00e9}y", 1, 2);
747 }
748
749 #[test]
752 fn line_comment_passes_through() {
753 let r = normalize_sql("SELECT 1 -- this is a comment");
754 assert_eq!(r.template, "SELECT ? -- this is a comment");
755 assert_eq!(r.params, vec!["1"]);
756 }
757
758 #[test]
759 fn block_comment_passes_through() {
760 let r = normalize_sql("SELECT /* comment */ 1 FROM t");
761 assert_eq!(r.template, "SELECT /* comment */ ? FROM t");
762 }
763
764 #[test]
765 fn comment_inside_string_not_treated_as_comment() {
766 let r = normalize_sql("SELECT * FROM t WHERE name = 'value -- not a comment'");
767 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
768 assert_eq!(r.params, vec!["value -- not a comment"]);
769 }
770
771 #[test]
774 fn unterminated_dollar_quote_flushed() {
775 let r = normalize_sql("SELECT $$incomplete");
776 assert_eq!(r.template, "SELECT ?");
777 assert_eq!(r.params, vec!["incomplete"]);
778 }
779
780 #[test]
781 fn unterminated_double_quote_flushed() {
782 let r = normalize_sql("SELECT \"unterminated");
783 assert_eq!(r.template, "SELECT \"unterminated");
784 assert!(r.params.is_empty());
785 }
786
787 #[test]
790 fn double_quoted_identifier_with_digits() {
791 let r = normalize_sql(r#"SELECT "col123" FROM "table456" WHERE id = 1"#);
792 assert_eq!(
793 r.template,
794 r#"SELECT "col123" FROM "table456" WHERE id = ?"#
795 );
796 assert_eq!(r.params, vec!["1"]);
797 }
798
799 #[test]
802 fn empty_dollar_quoted_string() {
803 let r = normalize_sql("SELECT $$$$ AS empty");
804 assert_eq!(r.template, "SELECT ? AS empty");
805 assert_eq!(r.params, vec![""]);
806 }
807
808 #[test]
811 fn long_query_truncated_at_max() {
812 let long_query = format!("SELECT * FROM t WHERE name = '{}'", "a".repeat(70_000));
813 let r = normalize_sql(&long_query);
814 assert!(r.template.len() <= 65_536 + 10); }
817
818 #[test]
821 fn whitespace_only_string_literal() {
822 let r = normalize_sql("SELECT * FROM t WHERE name = ' '");
823 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
824 assert_eq!(r.params, vec![" "]);
825 }
826
827 #[test]
830 fn four_consecutive_quotes() {
831 let r = normalize_sql("SELECT * FROM t WHERE name = ''''");
832 assert_eq!(r.template, "SELECT * FROM t WHERE name = ?");
833 assert_eq!(r.params, vec!["'"]);
834 }
835}