1use std::sync::LazyLock;
6
7use regex_lite::Regex;
8use serde::Serialize;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
12pub enum DdlOperation {
13 CreateTable {
15 table: String,
17 if_not_exists: bool,
19 },
20 DropTable {
22 table: String,
24 },
25 AlterTableAddColumn {
27 table: String,
29 column: String,
31 data_type: String,
33 has_default: bool,
39 is_not_null: bool,
45 if_not_exists: bool,
47 default_expr: Option<String>,
49 },
50 AlterTableDropColumn {
52 table: String,
54 column: String,
56 },
57 AlterTableAlterColumn {
59 table: String,
61 column: String,
63 },
64 CreateIndex {
66 name: String,
68 table: String,
70 is_concurrent: bool,
72 is_unique: bool,
74 },
75 DropIndex {
77 name: String,
79 },
80 CreateView {
82 name: String,
84 is_materialized: bool,
86 },
87 DropView {
89 name: String,
91 },
92 CreateFunction {
94 name: String,
96 },
97 DropFunction {
99 name: String,
101 },
102 AddConstraint {
104 table: String,
106 constraint_type: String,
108 },
109 DropConstraint {
111 table: String,
113 name: String,
115 },
116 CreateEnum {
118 name: String,
120 },
121 TruncateTable {
123 table: String,
125 },
126 Other {
128 statement_preview: String,
130 },
131}
132
133impl std::fmt::Display for DdlOperation {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 DdlOperation::CreateTable {
137 table,
138 if_not_exists,
139 } => {
140 if *if_not_exists {
141 write!(f, "CREATE TABLE IF NOT EXISTS {}", table)
142 } else {
143 write!(f, "CREATE TABLE {}", table)
144 }
145 }
146 DdlOperation::DropTable { table } => write!(f, "DROP TABLE {}", table),
147 DdlOperation::AlterTableAddColumn {
148 table,
149 column,
150 data_type,
151 if_not_exists,
152 ..
153 } => {
154 let ine = if *if_not_exists { "IF NOT EXISTS " } else { "" };
155 write!(
156 f,
157 "ALTER TABLE {} ADD COLUMN {}{} {}",
158 table, ine, column, data_type
159 )
160 }
161 DdlOperation::AlterTableDropColumn { table, column } => {
162 write!(f, "ALTER TABLE {} DROP COLUMN {}", table, column)
163 }
164 DdlOperation::AlterTableAlterColumn { table, column } => {
165 write!(f, "ALTER TABLE {} ALTER COLUMN {}", table, column)
166 }
167 DdlOperation::CreateIndex {
168 name,
169 table,
170 is_unique,
171 is_concurrent,
172 } => {
173 let unique = if *is_unique { "UNIQUE " } else { "" };
174 let concurrent = if *is_concurrent { "CONCURRENTLY " } else { "" };
175 write!(
176 f,
177 "CREATE {}{}INDEX {} ON {}",
178 unique, concurrent, name, table
179 )
180 }
181 DdlOperation::DropIndex { name } => write!(f, "DROP INDEX {}", name),
182 DdlOperation::CreateView {
183 name,
184 is_materialized,
185 } => {
186 if *is_materialized {
187 write!(f, "CREATE MATERIALIZED VIEW {}", name)
188 } else {
189 write!(f, "CREATE VIEW {}", name)
190 }
191 }
192 DdlOperation::DropView { name } => write!(f, "DROP VIEW {}", name),
193 DdlOperation::CreateFunction { name } => write!(f, "CREATE FUNCTION {}", name),
194 DdlOperation::DropFunction { name } => write!(f, "DROP FUNCTION {}", name),
195 DdlOperation::AddConstraint {
196 table,
197 constraint_type,
198 } => {
199 write!(
200 f,
201 "ALTER TABLE {} ADD {} CONSTRAINT",
202 table, constraint_type
203 )
204 }
205 DdlOperation::DropConstraint { table, name } => {
206 write!(f, "ALTER TABLE {} DROP CONSTRAINT {}", table, name)
207 }
208 DdlOperation::CreateEnum { name } => write!(f, "CREATE TYPE {} AS ENUM", name),
209 DdlOperation::TruncateTable { table } => write!(f, "TRUNCATE TABLE {}", table),
210 DdlOperation::Other { statement_preview } => write!(f, "{}", statement_preview),
211 }
212 }
213}
214
215static CREATE_TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
217 Regex::new(r"(?i)CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
218});
219
220static DROP_TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
221 Regex::new(r"(?i)DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
222});
223
224static ALTER_TABLE_DROP_COLUMN_RE: LazyLock<Regex> = LazyLock::new(|| {
225 Regex::new(
226 r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(\w+)",
227 )
228 .unwrap()
229});
230
231static ALTER_TABLE_ALTER_COLUMN_RE: LazyLock<Regex> = LazyLock::new(|| {
232 Regex::new(r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+ALTER\s+(?:COLUMN\s+)?(\w+)").unwrap()
233});
234
235static CREATE_INDEX_RE: LazyLock<Regex> = LazyLock::new(|| {
236 Regex::new(r"(?i)CREATE\s+(UNIQUE\s+)?INDEX\s+(CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s+ON\s+(?:(\w+)\.)?(\w+)").unwrap()
237});
238
239static DROP_INDEX_RE: LazyLock<Regex> = LazyLock::new(|| {
240 Regex::new(r"(?i)DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)")
241 .unwrap()
242});
243
244static CREATE_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
245 Regex::new(r"(?i)CREATE\s+(?:OR\s+REPLACE\s+)?(MATERIALIZED\s+)?VIEW\s+(?:(\w+)\.)?(\w+)")
246 .unwrap()
247});
248
249static DROP_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
250 Regex::new(r"(?i)DROP\s+(MATERIALIZED\s+)?VIEW\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
251});
252
253static CREATE_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
254 Regex::new(r"(?i)CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:(\w+)\.)?(\w+)").unwrap()
255});
256
257static DROP_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
258 Regex::new(r"(?i)DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
259});
260
261static ADD_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
262 Regex::new(r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+ADD\s+(?:CONSTRAINT\s+\w+\s+)?(PRIMARY\s+KEY|UNIQUE|FOREIGN\s+KEY|CHECK|EXCLUDE)").unwrap()
263});
264
265static DROP_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
266 Regex::new(
267 r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(\w+)",
268 )
269 .unwrap()
270});
271
272static CREATE_ENUM_RE: LazyLock<Regex> =
273 LazyLock::new(|| Regex::new(r"(?i)CREATE\s+TYPE\s+(?:(\w+)\.)?(\w+)\s+AS\s+ENUM").unwrap());
274
275static TRUNCATE_RE: LazyLock<Regex> =
276 LazyLock::new(|| Regex::new(r"(?i)TRUNCATE\s+(?:TABLE\s+)?(?:(\w+)\.)?(\w+)").unwrap());
277
278#[derive(Debug, Clone)]
284pub struct LocatedDdl {
285 pub op: DdlOperation,
287 pub start: usize,
289 pub end: usize,
291 pub focus: usize,
294}
295
296pub fn extract_ddl_operations(sql: &str) -> Vec<DdlOperation> {
302 extract_ddl_operations_located(sql)
303 .into_iter()
304 .map(|l| l.op)
305 .collect()
306}
307
308pub fn extract_ddl_operations_located(sql: &str) -> Vec<LocatedDdl> {
310 let stripped = strip_comments(sql);
311 let mut ops = Vec::new();
312
313 for (start, end) in statement_ranges(&stripped) {
314 let stmt = &stripped[start..end];
315 let parsed = parse_statement_ops(stmt);
316 if parsed.is_empty() {
317 let raw = &sql[start..end];
320 let preview: String = raw.chars().take(80).collect();
321 let preview = if raw.len() > 80 {
322 format!("{}...", preview)
323 } else {
324 preview
325 };
326 ops.push(LocatedDdl {
327 op: DdlOperation::Other {
328 statement_preview: preview,
329 },
330 start,
331 end,
332 focus: start,
333 });
334 continue;
335 }
336 for (op, offset) in parsed {
337 ops.push(LocatedDdl {
338 op,
339 start,
340 end,
341 focus: start + offset,
342 });
343 }
344 }
345
346 ops
347}
348
349fn parse_statement_ops(stmt: &str) -> Vec<(DdlOperation, usize)> {
354 if let Some(caps) = ADD_CONSTRAINT_RE.captures(stmt) {
358 let table = caps.get(2).unwrap().as_str().to_string();
359 let constraint_type = caps.get(3).unwrap().as_str().to_uppercase();
360 return vec![(
361 DdlOperation::AddConstraint {
362 table,
363 constraint_type,
364 },
365 caps.get(0).unwrap().start(),
366 )];
367 }
368
369 if let Some(caps) = DROP_CONSTRAINT_RE.captures(stmt) {
371 let table = caps.get(2).unwrap().as_str().to_string();
372 let name = caps.get(3).unwrap().as_str().to_string();
373 return vec![(
374 DdlOperation::DropConstraint { table, name },
375 caps.get(0).unwrap().start(),
376 )];
377 }
378
379 if let Some(caps) = ALTER_TABLE_ALTER_COLUMN_RE.captures(stmt) {
381 let table = caps.get(2).unwrap().as_str().to_string();
382 let column = caps.get(3).unwrap().as_str().to_string();
383 return vec![(
384 DdlOperation::AlterTableAlterColumn { table, column },
385 caps.get(0).unwrap().start(),
386 )];
387 }
388
389 if let Some(caps) = ALTER_TABLE_DROP_COLUMN_RE.captures(stmt) {
391 let table = caps.get(2).unwrap().as_str().to_string();
392 let column = caps.get(3).unwrap().as_str().to_string();
393 return vec![(
394 DdlOperation::AlterTableDropColumn { table, column },
395 caps.get(0).unwrap().start(),
396 )];
397 }
398
399 if let Some((table, clauses)) = parse_add_columns(stmt) {
401 return clauses
402 .into_iter()
403 .map(|c| {
404 (
405 DdlOperation::AlterTableAddColumn {
406 table: table.clone(),
407 column: c.column,
408 data_type: c.data_type,
409 has_default: c.has_default,
410 is_not_null: c.is_not_null,
411 if_not_exists: c.if_not_exists,
412 default_expr: c.default_expr,
413 },
414 c.column_offset,
415 )
416 })
417 .collect();
418 }
419
420 if let Some(caps) = CREATE_TABLE_RE.captures(stmt) {
422 let if_not_exists = caps.get(1).is_some();
423 let table = caps.get(3).unwrap().as_str().to_string();
424 return vec![(
425 DdlOperation::CreateTable {
426 table,
427 if_not_exists,
428 },
429 caps.get(0).unwrap().start(),
430 )];
431 }
432
433 if let Some(caps) = DROP_TABLE_RE.captures(stmt) {
435 let table = caps.get(2).unwrap().as_str().to_string();
436 return vec![(
437 DdlOperation::DropTable { table },
438 caps.get(0).unwrap().start(),
439 )];
440 }
441
442 if let Some(caps) = CREATE_INDEX_RE.captures(stmt) {
444 let is_unique = caps.get(1).is_some();
445 let is_concurrent = caps.get(2).is_some();
446 let name = caps.get(3).unwrap().as_str().to_string();
447 let table = caps.get(5).unwrap().as_str().to_string();
448 return vec![(
449 DdlOperation::CreateIndex {
450 name,
451 table,
452 is_concurrent,
453 is_unique,
454 },
455 caps.get(0).unwrap().start(),
456 )];
457 }
458
459 if let Some(caps) = DROP_INDEX_RE.captures(stmt) {
461 let name = caps.get(2).unwrap().as_str().to_string();
462 return vec![(
463 DdlOperation::DropIndex { name },
464 caps.get(0).unwrap().start(),
465 )];
466 }
467
468 if let Some(caps) = CREATE_VIEW_RE.captures(stmt) {
470 let is_materialized = caps.get(1).is_some();
471 let name = caps.get(3).unwrap().as_str().to_string();
472 return vec![(
473 DdlOperation::CreateView {
474 name,
475 is_materialized,
476 },
477 caps.get(0).unwrap().start(),
478 )];
479 }
480
481 if let Some(caps) = DROP_VIEW_RE.captures(stmt) {
483 let name = caps.get(3).unwrap().as_str().to_string();
484 return vec![(
485 DdlOperation::DropView { name },
486 caps.get(0).unwrap().start(),
487 )];
488 }
489
490 if let Some(caps) = CREATE_FUNCTION_RE.captures(stmt) {
492 let name = caps.get(2).unwrap().as_str().to_string();
493 return vec![(
494 DdlOperation::CreateFunction { name },
495 caps.get(0).unwrap().start(),
496 )];
497 }
498
499 if let Some(caps) = DROP_FUNCTION_RE.captures(stmt) {
501 let name = caps.get(2).unwrap().as_str().to_string();
502 return vec![(
503 DdlOperation::DropFunction { name },
504 caps.get(0).unwrap().start(),
505 )];
506 }
507
508 if let Some(caps) = CREATE_ENUM_RE.captures(stmt) {
510 let name = caps.get(2).unwrap().as_str().to_string();
511 return vec![(
512 DdlOperation::CreateEnum { name },
513 caps.get(0).unwrap().start(),
514 )];
515 }
516
517 if let Some(caps) = TRUNCATE_RE.captures(stmt) {
519 let table = caps.get(2).unwrap().as_str().to_string();
520 return vec![(
521 DdlOperation::TruncateTable { table },
522 caps.get(0).unwrap().start(),
523 )];
524 }
525
526 Vec::new()
527}
528
529#[derive(Debug, Clone)]
535struct AddColumnClause {
536 column: String,
537 column_offset: usize,
539 data_type: String,
540 if_not_exists: bool,
541 is_not_null: bool,
542 has_default: bool,
543 default_expr: Option<String>,
544}
545
546const COLUMN_CONSTRAINT_KEYWORDS: &[&str] = &[
550 "NOT",
551 "NULL",
552 "DEFAULT",
553 "CHECK",
554 "UNIQUE",
555 "PRIMARY",
556 "REFERENCES",
557 "CONSTRAINT",
558 "GENERATED",
559 "COLLATE",
560 "DEFERRABLE",
561 "INITIALLY",
562 "COMMENT",
563 "AUTO_INCREMENT",
564 "IDENTITY",
565 "STORAGE",
566 "COMPRESSION",
567 "VISIBLE",
568 "INVISIBLE",
569 "FIRST",
570 "AFTER",
571];
572
573const TABLE_CONSTRAINT_KEYWORDS: &[&str] = &[
576 "CONSTRAINT",
577 "PRIMARY",
578 "UNIQUE",
579 "FOREIGN",
580 "CHECK",
581 "EXCLUDE",
582 "INDEX",
583 "KEY",
584 "FULLTEXT",
585 "SPATIAL",
586];
587
588fn parse_add_columns(stmt: &str) -> Option<(String, Vec<AddColumnClause>)> {
598 let toks = tokenize(stmt);
599
600 let mut i = toks
602 .windows(2)
603 .position(|w| is_kw(&w[0], "ALTER") && is_kw(&w[1], "TABLE"))?
604 + 2;
605
606 if kw_at(&toks, i, "IF") && kw_at(&toks, i + 1, "EXISTS") {
608 i += 2;
609 }
610 if kw_at(&toks, i, "ONLY") {
611 i += 1;
612 }
613
614 let mut name_parts: Vec<&str> = Vec::new();
616 loop {
617 let t = toks.get(i)?;
618 if !is_identifier(t) {
619 return None;
620 }
621 name_parts.push(t.text);
622 i += 1;
623 match toks.get(i) {
624 Some(t) if t.kind == TokKind::Punct && t.text == "." => i += 1,
625 _ => break,
626 }
627 }
628 if toks
630 .get(i)
631 .is_some_and(|t| t.kind == TokKind::Punct && t.text == "*")
632 {
633 i += 1;
634 }
635 let table = (*name_parts.last()?).to_string();
636
637 let mut clauses = Vec::new();
639 let mut depth = 0usize;
640 while i < toks.len() {
641 let t = &toks[i];
642 if t.kind == TokKind::Punct {
643 match t.text {
644 "(" => depth += 1,
645 ")" => depth = depth.saturating_sub(1),
646 _ => {}
647 }
648 i += 1;
649 continue;
650 }
651 if depth == 0 && is_kw(t, "ADD") {
652 if let Some((clause, next)) = parse_one_add_column(stmt, &toks, i + 1) {
653 clauses.push(clause);
654 i = next;
655 continue;
656 }
657 }
658 i += 1;
659 }
660
661 if clauses.is_empty() {
662 None
663 } else {
664 Some((table, clauses))
665 }
666}
667
668fn parse_one_add_column<'a>(
673 stmt: &'a str,
674 toks: &[Tok<'a>],
675 mut i: usize,
676) -> Option<(AddColumnClause, usize)> {
677 if kw_at(toks, i, "COLUMN") {
678 i += 1;
679 }
680
681 let mut if_not_exists = false;
682 if kw_at(toks, i, "IF") && kw_at(toks, i + 1, "NOT") && kw_at(toks, i + 2, "EXISTS") {
683 if_not_exists = true;
684 i += 3;
685 }
686
687 let col = toks.get(i)?;
688 if !is_identifier(col) {
689 return None;
690 }
691 if col.kind == TokKind::Word
693 && TABLE_CONSTRAINT_KEYWORDS
694 .iter()
695 .any(|k| col.text.eq_ignore_ascii_case(k))
696 {
697 return None;
698 }
699 let column = col.text.to_string();
700 let column_offset = col.start;
701 i += 1;
702
703 let type_first = i;
706 let mut depth = 0usize;
707 while i < toks.len() {
708 let t = &toks[i];
709 if t.kind == TokKind::Punct {
710 match t.text {
711 "(" => depth += 1,
712 ")" => {
713 if depth == 0 {
714 break;
715 }
716 depth -= 1;
717 }
718 "," if depth == 0 => break,
719 _ => {}
720 }
721 i += 1;
722 continue;
723 }
724 if depth == 0
725 && t.kind == TokKind::Word
726 && COLUMN_CONSTRAINT_KEYWORDS
727 .iter()
728 .any(|k| t.text.eq_ignore_ascii_case(k))
729 {
730 break;
731 }
732 i += 1;
733 }
734 let data_type = if i > type_first {
735 normalize_whitespace(&stmt[toks[type_first].start..toks[i - 1].end])
736 } else {
737 "unknown".to_string()
738 };
739
740 let mut is_not_null = false;
742 let mut default_expr = None;
743 let mut depth = 0usize;
744 while i < toks.len() {
745 let t = &toks[i];
746 if t.kind == TokKind::Punct {
747 match t.text {
748 "(" => depth += 1,
749 ")" => {
750 if depth == 0 {
751 break;
752 }
753 depth -= 1;
754 }
755 "," if depth == 0 => {
756 i += 1;
757 break;
758 }
759 _ => {}
760 }
761 i += 1;
762 continue;
763 }
764 if depth == 0 && t.kind == TokKind::Word {
765 if is_kw(t, "NOT") && kw_at(toks, i + 1, "NULL") {
766 is_not_null = true;
767 i += 2;
768 continue;
769 }
770 if is_kw(t, "DEFAULT") {
771 let (expr, next) = read_default_expr(stmt, toks, i + 1);
772 default_expr = Some(expr);
773 i = next;
774 continue;
775 }
776 }
777 i += 1;
778 }
779
780 Some((
781 AddColumnClause {
782 column,
783 column_offset,
784 data_type,
785 if_not_exists,
786 is_not_null,
787 has_default: default_expr.is_some(),
788 default_expr,
789 },
790 i,
791 ))
792}
793
794fn read_default_expr(stmt: &str, toks: &[Tok<'_>], start: usize) -> (String, usize) {
800 let mut i = start;
801 let mut depth = 0usize;
802 while i < toks.len() {
803 let t = &toks[i];
804 if t.kind == TokKind::Punct {
805 match t.text {
806 "(" => depth += 1,
807 ")" => {
808 if depth == 0 {
809 break;
810 }
811 depth -= 1;
812 }
813 "," if depth == 0 => break,
814 _ => {}
815 }
816 i += 1;
817 continue;
818 }
819 if depth == 0
820 && i > start
821 && t.kind == TokKind::Word
822 && COLUMN_CONSTRAINT_KEYWORDS
823 .iter()
824 .any(|k| t.text.eq_ignore_ascii_case(k))
825 {
826 break;
827 }
828 i += 1;
829 }
830
831 let expr = if i > start {
832 normalize_whitespace(&stmt[toks[start].start..toks[i - 1].end])
833 } else {
834 String::new()
835 };
836 (expr, i)
837}
838
839fn normalize_whitespace(s: &str) -> String {
841 s.split_whitespace().collect::<Vec<_>>().join(" ")
842}
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
849enum TokKind {
850 Word,
852 Ident,
854 Literal,
856 Punct,
858}
859
860#[derive(Debug, Clone, Copy)]
861struct Tok<'a> {
862 kind: TokKind,
863 text: &'a str,
864 start: usize,
865 end: usize,
866}
867
868fn is_kw(tok: &Tok<'_>, kw: &str) -> bool {
869 tok.kind == TokKind::Word && tok.text.eq_ignore_ascii_case(kw)
870}
871
872fn kw_at(toks: &[Tok<'_>], i: usize, kw: &str) -> bool {
873 toks.get(i).is_some_and(|t| is_kw(t, kw))
874}
875
876fn is_identifier(tok: &Tok<'_>) -> bool {
878 match tok.kind {
879 TokKind::Ident => true,
880 TokKind::Word => tok
881 .text
882 .starts_with(|c: char| c.is_alphabetic() || c == '_'),
883 _ => false,
884 }
885}
886
887fn tokenize(sql: &str) -> Vec<Tok<'_>> {
889 let bytes = sql.as_bytes();
890 let len = bytes.len();
891 let mut toks = Vec::new();
892 let mut i = 0;
893
894 while i < len {
895 let c = bytes[i];
896 if c.is_ascii_whitespace() {
897 i += 1;
898 continue;
899 }
900 if let Some(j) = skip_comment(bytes, i) {
901 i = j;
902 continue;
903 }
904 if c == b'\'' {
905 let j = skip_quoted(sql, i).unwrap_or(len);
906 toks.push(Tok {
907 kind: TokKind::Literal,
908 text: &sql[i..j],
909 start: i,
910 end: j,
911 });
912 i = j;
913 continue;
914 }
915 if c == b'"' || c == b'`' {
916 let mut j = i + 1;
917 while j < len {
918 if bytes[j] == c {
919 if j + 1 < len && bytes[j + 1] == c {
920 j += 2;
921 continue;
922 }
923 break;
924 }
925 j += 1;
926 }
927 let inner_end = j.min(len);
928 let end = (j + 1).min(len);
929 toks.push(Tok {
930 kind: TokKind::Ident,
931 text: &sql[i + 1..inner_end],
932 start: i,
933 end,
934 });
935 i = end;
936 continue;
937 }
938 if c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 {
939 let mut j = i;
940 while j < len
941 && (bytes[j].is_ascii_alphanumeric()
942 || bytes[j] == b'_'
943 || bytes[j] == b'$'
944 || bytes[j] >= 0x80)
945 {
946 j += 1;
947 }
948 toks.push(Tok {
949 kind: TokKind::Word,
950 text: &sql[i..j],
951 start: i,
952 end: j,
953 });
954 i = j;
955 continue;
956 }
957 toks.push(Tok {
958 kind: TokKind::Punct,
959 text: &sql[i..i + 1],
960 start: i,
961 end: i + 1,
962 });
963 i += 1;
964 }
965
966 toks
967}
968
969pub fn split_statements(sql: &str) -> Vec<&str> {
972 statement_ranges(sql)
973 .into_iter()
974 .map(|(s, e)| &sql[s..e])
975 .collect()
976}
977
978fn statement_ranges(sql: &str) -> Vec<(usize, usize)> {
981 let bytes = sql.as_bytes();
982 let len = bytes.len();
983 let mut ranges = Vec::new();
984 let mut start = 0;
985 let mut i = 0;
986
987 while i < len {
988 if let Some(j) = skip_comment(bytes, i) {
989 i = j;
990 continue;
991 }
992 if let Some(j) = skip_quoted(sql, i) {
993 i = j;
994 continue;
995 }
996 if bytes[i] == b';' {
997 if let Some(r) = trim_range(sql, start, i) {
998 ranges.push(r);
999 }
1000 i += 1;
1001 start = i;
1002 continue;
1003 }
1004 i += 1;
1005 }
1006
1007 if let Some(r) = trim_range(sql, start, len) {
1009 ranges.push(r);
1010 }
1011
1012 ranges
1013}
1014
1015fn trim_range(sql: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1018 let slice = &sql[start..end];
1019 if slice.trim().is_empty() {
1020 return None;
1021 }
1022 let lead = slice.len() - slice.trim_start().len();
1023 let trail = slice.len() - slice.trim_end().len();
1024 Some((start + lead, end - trail))
1025}
1026
1027fn skip_comment(bytes: &[u8], i: usize) -> Option<usize> {
1032 let len = bytes.len();
1033 if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1034 let mut j = i + 2;
1035 while j < len && bytes[j] != b'\n' {
1036 j += 1;
1037 }
1038 return Some(j);
1039 }
1040 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1041 let mut j = i + 2;
1042 let mut depth = 1usize;
1043 while j < len && depth > 0 {
1044 if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
1045 depth += 1;
1046 j += 2;
1047 } else if j + 1 < len && bytes[j] == b'*' && bytes[j + 1] == b'/' {
1048 depth -= 1;
1049 j += 2;
1050 } else {
1051 j += 1;
1052 }
1053 }
1054 return Some(j.min(len));
1055 }
1056 None
1057}
1058
1059fn skip_quoted(sql: &str, i: usize) -> Option<usize> {
1065 let bytes = sql.as_bytes();
1066 let len = bytes.len();
1067 match bytes[i] {
1068 b'\'' => {
1069 let is_escape_string = i > 0
1071 && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
1072 && (i < 2 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_'));
1073 let mut j = i + 1;
1074 while j < len {
1075 if is_escape_string && bytes[j] == b'\\' {
1076 j += 2;
1077 continue;
1078 }
1079 if bytes[j] == b'\'' {
1080 if j + 1 < len && bytes[j + 1] == b'\'' {
1081 j += 2; } else {
1083 j += 1;
1084 break;
1085 }
1086 } else {
1087 j += 1;
1088 }
1089 }
1090 Some(j.min(len))
1091 }
1092 q @ (b'"' | b'`') => {
1093 let mut j = i + 1;
1094 while j < len {
1095 if bytes[j] == q {
1096 if j + 1 < len && bytes[j + 1] == q {
1097 j += 2; continue;
1099 }
1100 j += 1;
1101 break;
1102 }
1103 j += 1;
1104 }
1105 Some(j.min(len))
1106 }
1107 b'$' => {
1109 let tag_start = i;
1110 let mut j = i + 1;
1111 while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
1112 j += 1;
1113 }
1114 if j < len && bytes[j] == b'$' {
1115 let tag = &sql[tag_start..=j];
1116 j += 1;
1117 while j < len {
1118 if bytes[j] == b'$' && sql[j..].starts_with(tag) {
1119 j += tag.len();
1120 break;
1121 }
1122 j += 1;
1123 }
1124 }
1125 Some(j.min(len))
1126 }
1127 _ => None,
1128 }
1129}
1130
1131pub fn strip_comments(sql: &str) -> String {
1138 let bytes = sql.as_bytes();
1139 let len = bytes.len();
1140 let mut out = bytes.to_vec();
1141 let mut i = 0;
1142
1143 while i < len {
1144 if let Some(j) = skip_comment(bytes, i) {
1145 for b in &mut out[i..j] {
1146 if *b != b'\n' {
1147 *b = b' ';
1148 }
1149 }
1150 i = j;
1151 continue;
1152 }
1153 if let Some(j) = skip_quoted(sql, i) {
1154 i = j;
1155 continue;
1156 }
1157 i += 1;
1158 }
1159
1160 String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1163}
1164
1165pub fn line_number_at(sql: &str, offset: usize) -> usize {
1167 sql[..offset.min(sql.len())]
1168 .bytes()
1169 .filter(|b| *b == b'\n')
1170 .count()
1171 + 1
1172}
1173
1174pub fn split_mysql_statements(sql: &str) -> Vec<String> {
1185 let bytes = sql.as_bytes();
1186 let len = bytes.len();
1187 let mut out = Vec::new();
1188 let mut start = 0;
1189 let mut i = 0;
1190 while i < len {
1191 let c = bytes[i];
1192 if c == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1194 while i < len && bytes[i] != b'\n' {
1195 i += 1;
1196 }
1197 continue;
1198 }
1199 if c == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1201 i += 2;
1202 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1203 i += 1;
1204 }
1205 i = (i + 2).min(len);
1206 continue;
1207 }
1208 if c == b'\'' {
1210 i += 1;
1211 while i < len && bytes[i] != b'\'' {
1212 if bytes[i] == b'\\' && i + 1 < len {
1213 i += 2;
1214 } else {
1215 i += 1;
1216 }
1217 }
1218 i += 1;
1219 continue;
1220 }
1221 if c == b'"' {
1223 i += 1;
1224 while i < len && bytes[i] != b'"' {
1225 if bytes[i] == b'\\' && i + 1 < len {
1226 i += 2;
1227 } else {
1228 i += 1;
1229 }
1230 }
1231 i += 1;
1232 continue;
1233 }
1234 if c == b'`' {
1236 i += 1;
1237 while i < len && bytes[i] != b'`' {
1238 i += 1;
1239 }
1240 i += 1;
1241 continue;
1242 }
1243 if c == b';' {
1245 out.push(sql[start..i].to_string());
1246 i += 1;
1247 start = i;
1248 continue;
1249 }
1250 i += 1;
1251 }
1252 let tail = sql[start..].trim();
1253 if !tail.is_empty() {
1254 out.push(tail.to_string());
1255 }
1256 out
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262
1263 #[test]
1264 fn test_split_simple_statements() {
1265 let sql = "SELECT 1; SELECT 2;";
1266 let stmts = split_statements(sql);
1267 assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
1268 }
1269
1270 #[test]
1271 fn test_split_respects_string_literals() {
1272 let sql = "SELECT 'hello;world'; SELECT 2;";
1273 let stmts = split_statements(sql);
1274 assert_eq!(stmts, vec!["SELECT 'hello;world'", "SELECT 2"]);
1275 }
1276
1277 #[test]
1278 fn test_split_respects_dollar_quoting() {
1279 let sql =
1280 "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql; SELECT 1;";
1281 let stmts = split_statements(sql);
1282 assert_eq!(stmts.len(), 2);
1283 assert!(stmts[0].contains("BEGIN; END;"));
1284 }
1285
1286 #[test]
1287 fn test_split_respects_tagged_dollar_quoting() {
1288 let sql = "CREATE FUNCTION foo() RETURNS void AS $body$ BEGIN; END; $body$ LANGUAGE plpgsql; SELECT 1;";
1289 let stmts = split_statements(sql);
1290 assert_eq!(stmts.len(), 2);
1291 assert!(stmts[0].contains("BEGIN; END;"));
1292 }
1293
1294 #[test]
1295 fn test_split_respects_comments() {
1296 let sql = "-- This is a comment with ; semicolon\nSELECT 1;";
1297 let stmts = split_statements(sql);
1298 assert_eq!(stmts.len(), 1);
1299 }
1300
1301 #[test]
1302 fn test_split_no_trailing_semicolon() {
1303 let sql = "SELECT 1";
1304 let stmts = split_statements(sql);
1305 assert_eq!(stmts, vec!["SELECT 1"]);
1306 }
1307
1308 #[test]
1309 fn test_extract_create_table() {
1310 let sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);";
1311 let ops = extract_ddl_operations(sql);
1312 assert_eq!(ops.len(), 1);
1313 match &ops[0] {
1314 DdlOperation::CreateTable {
1315 table,
1316 if_not_exists,
1317 } => {
1318 assert_eq!(table, "users");
1319 assert!(!if_not_exists);
1320 }
1321 _ => panic!("Expected CreateTable"),
1322 }
1323 }
1324
1325 #[test]
1326 fn test_extract_create_table_if_not_exists() {
1327 let sql = "CREATE TABLE IF NOT EXISTS users (id SERIAL);";
1328 let ops = extract_ddl_operations(sql);
1329 match &ops[0] {
1330 DdlOperation::CreateTable {
1331 table,
1332 if_not_exists,
1333 } => {
1334 assert_eq!(table, "users");
1335 assert!(if_not_exists);
1336 }
1337 _ => panic!("Expected CreateTable"),
1338 }
1339 }
1340
1341 #[test]
1342 fn test_extract_add_column() {
1343 let sql = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';";
1344 let ops = extract_ddl_operations(sql);
1345 match &ops[0] {
1346 DdlOperation::AlterTableAddColumn {
1347 table,
1348 column,
1349 is_not_null,
1350 has_default,
1351 ..
1352 } => {
1353 assert_eq!(table, "users");
1354 assert_eq!(column, "email");
1355 assert!(is_not_null);
1356 assert!(has_default);
1357 }
1358 _ => panic!("Expected AlterTableAddColumn"),
1359 }
1360 }
1361
1362 fn add_column(sql: &str) -> DdlOperation {
1364 let ops = extract_ddl_operations(sql);
1365 assert_eq!(ops.len(), 1, "expected exactly one op, got {:?}", ops);
1366 ops.into_iter().next().unwrap()
1367 }
1368
1369 #[test]
1370 fn test_add_column_if_not_exists_names_the_column() {
1371 match add_column(
1372 "ALTER TABLE dicom.reid_shares ADD COLUMN IF NOT EXISTS threshold smallint;",
1373 ) {
1374 DdlOperation::AlterTableAddColumn {
1375 table,
1376 column,
1377 data_type,
1378 is_not_null,
1379 has_default,
1380 if_not_exists,
1381 default_expr,
1382 } => {
1383 assert_eq!(table, "reid_shares");
1384 assert_eq!(column, "threshold");
1385 assert_eq!(data_type, "smallint");
1386 assert!(default_expr.is_none());
1387 assert!(!is_not_null);
1388 assert!(!has_default);
1389 assert!(if_not_exists);
1390 }
1391 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1392 }
1393 }
1394
1395 #[test]
1396 fn test_add_column_ignores_not_null_in_comments() {
1397 let sql = "-- Every ceremony writes the threshold NOT NULL.\n\
1398 ALTER TABLE dicom.reid_shares\n \
1399 ADD COLUMN IF NOT EXISTS threshold smallint\n \
1400 CHECK (threshold IS NULL OR threshold BETWEEN 1 AND 255);";
1401 match add_column(sql) {
1402 DdlOperation::AlterTableAddColumn {
1403 column,
1404 is_not_null,
1405 ..
1406 } => {
1407 assert_eq!(column, "threshold");
1408 assert!(!is_not_null, "NOT NULL came from a comment");
1409 }
1410 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1411 }
1412 }
1413
1414 #[test]
1415 fn test_add_column_ignores_not_null_inside_check() {
1416 match add_column("ALTER TABLE t ADD COLUMN c text CHECK (c IS NOT NULL);") {
1417 DdlOperation::AlterTableAddColumn { is_not_null, .. } => assert!(!is_not_null),
1418 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1419 }
1420 }
1421
1422 #[test]
1423 fn test_add_column_ignores_keywords_inside_string_literals() {
1424 match add_column("ALTER TABLE t ADD COLUMN c text DEFAULT 'NOT NULL';") {
1425 DdlOperation::AlterTableAddColumn {
1426 is_not_null,
1427 has_default,
1428 ..
1429 } => {
1430 assert!(!is_not_null);
1431 assert!(has_default);
1432 }
1433 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1434 }
1435 }
1436
1437 #[test]
1438 fn test_add_column_quoted_and_parenthesised_type() {
1439 match add_column(
1440 r#"ALTER TABLE "my schema"."my table" ADD "my col" numeric(10,2) NOT NULL;"#,
1441 ) {
1442 DdlOperation::AlterTableAddColumn {
1443 table,
1444 column,
1445 data_type,
1446 is_not_null,
1447 ..
1448 } => {
1449 assert_eq!(table, "my table");
1450 assert_eq!(column, "my col");
1451 assert_eq!(data_type, "numeric(10,2)");
1452 assert!(is_not_null);
1453 }
1454 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1455 }
1456 }
1457
1458 #[test]
1459 fn test_add_column_multiword_type() {
1460 match add_column("ALTER TABLE t ADD COLUMN c timestamp with time zone NOT NULL;") {
1461 DdlOperation::AlterTableAddColumn {
1462 data_type,
1463 is_not_null,
1464 ..
1465 } => {
1466 assert_eq!(data_type, "timestamp with time zone");
1467 assert!(is_not_null);
1468 }
1469 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1470 }
1471 }
1472
1473 #[test]
1474 fn test_add_multiple_columns_in_one_statement() {
1475 let ops = extract_ddl_operations(
1476 "ALTER TABLE t ADD COLUMN a int, ADD COLUMN IF NOT EXISTS b text NOT NULL;",
1477 );
1478 assert_eq!(ops.len(), 2);
1479 match (&ops[0], &ops[1]) {
1480 (
1481 DdlOperation::AlterTableAddColumn {
1482 column: c1,
1483 is_not_null: n1,
1484 ..
1485 },
1486 DdlOperation::AlterTableAddColumn {
1487 column: c2,
1488 is_not_null: n2,
1489 if_not_exists,
1490 ..
1491 },
1492 ) => {
1493 assert_eq!(c1, "a");
1494 assert!(!n1);
1495 assert_eq!(c2, "b");
1496 assert!(n2);
1497 assert!(if_not_exists);
1498 }
1499 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1500 }
1501 }
1502
1503 #[test]
1504 fn test_add_column_captures_default_expression() {
1505 for (sql, expected) in [
1506 (
1507 "ALTER TABLE t ADD COLUMN c timestamptz DEFAULT now();",
1508 "now()",
1509 ),
1510 ("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 5;", "5"),
1511 (
1512 "ALTER TABLE t ADD COLUMN c text DEFAULT 'x' NOT NULL;",
1513 "'x'",
1514 ),
1515 ("ALTER TABLE t ADD COLUMN c text DEFAULT NULL;", "NULL"),
1516 (
1517 "ALTER TABLE t ADD COLUMN c text[] DEFAULT '{}' CHECK (c IS NOT NULL);",
1518 "'{}'",
1519 ),
1520 ] {
1521 match add_column(sql) {
1522 DdlOperation::AlterTableAddColumn { default_expr, .. } => {
1523 assert_eq!(default_expr.as_deref(), Some(expected), "for {}", sql)
1524 }
1525 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1526 }
1527 }
1528 }
1529
1530 #[test]
1531 fn test_add_column_default_is_per_clause() {
1532 let ops = extract_ddl_operations(
1533 "ALTER TABLE t ADD COLUMN a text[] DEFAULT '{}', ADD COLUMN b timestamptz NOT NULL DEFAULT now();",
1534 );
1535 assert_eq!(ops.len(), 2);
1536 match (&ops[0], &ops[1]) {
1537 (
1538 DdlOperation::AlterTableAddColumn {
1539 default_expr: a, ..
1540 },
1541 DdlOperation::AlterTableAddColumn {
1542 default_expr: b, ..
1543 },
1544 ) => {
1545 assert_eq!(a.as_deref(), Some("'{}'"));
1546 assert_eq!(b.as_deref(), Some("now()"));
1547 }
1548 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1549 }
1550 }
1551
1552 #[test]
1553 fn test_add_constraint_is_not_parsed_as_a_column() {
1554 let ops = extract_ddl_operations("ALTER TABLE t ADD CONSTRAINT t_pk PRIMARY KEY (id);");
1555 assert!(matches!(ops[0], DdlOperation::AddConstraint { .. }));
1556 }
1557
1558 #[test]
1559 fn test_located_ops_point_at_the_column() {
1560 let sql =
1561 "-- comment with NOT NULL\n-- another\nALTER TABLE t\n ADD COLUMN c int NOT NULL;";
1562 let located = extract_ddl_operations_located(sql);
1563 assert_eq!(located.len(), 1);
1564 assert_eq!(line_number_at(sql, located[0].start), 3);
1566 assert_eq!(line_number_at(sql, located[0].focus), 4);
1567 assert_eq!(&sql[located[0].focus..located[0].focus + 1], "c");
1568 }
1569
1570 #[test]
1571 fn test_strip_comments_preserves_offsets_and_lines() {
1572 let sql = "-- NOT NULL\nSELECT 1; /* NOT NULL */\nSELECT 'not -- a comment';";
1573 let stripped = strip_comments(sql);
1574 assert_eq!(stripped.len(), sql.len());
1575 assert_eq!(stripped.lines().count(), sql.lines().count());
1576 assert!(!stripped.to_uppercase().contains("NOT NULL"));
1577 assert!(stripped.contains("'not -- a comment'"));
1578 }
1579
1580 #[test]
1581 fn test_line_number_at_is_one_based() {
1582 let sql = "a\nb\nc";
1583 assert_eq!(line_number_at(sql, 0), 1);
1584 assert_eq!(line_number_at(sql, 2), 2);
1585 assert_eq!(line_number_at(sql, 4), 3);
1586 }
1587
1588 #[test]
1589 fn test_extract_create_index() {
1590 let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email);";
1591 let ops = extract_ddl_operations(sql);
1592 match &ops[0] {
1593 DdlOperation::CreateIndex {
1594 name,
1595 table,
1596 is_concurrent,
1597 is_unique,
1598 } => {
1599 assert_eq!(name, "idx_users_email");
1600 assert_eq!(table, "users");
1601 assert!(is_concurrent);
1602 assert!(is_unique);
1603 }
1604 _ => panic!("Expected CreateIndex"),
1605 }
1606 }
1607
1608 #[test]
1609 fn test_extract_create_function() {
1610 let sql = "CREATE OR REPLACE FUNCTION my_func() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;";
1611 let ops = extract_ddl_operations(sql);
1612 match &ops[0] {
1613 DdlOperation::CreateFunction { name } => {
1614 assert_eq!(name, "my_func");
1615 }
1616 _ => panic!("Expected CreateFunction, got {:?}", ops[0]),
1617 }
1618 }
1619
1620 #[test]
1621 fn test_extract_create_enum() {
1622 let sql = "CREATE TYPE mood AS ENUM ('happy', 'sad');";
1623 let ops = extract_ddl_operations(sql);
1624 match &ops[0] {
1625 DdlOperation::CreateEnum { name } => {
1626 assert_eq!(name, "mood");
1627 }
1628 _ => panic!("Expected CreateEnum"),
1629 }
1630 }
1631
1632 #[test]
1633 fn test_extract_multiple() {
1634 let sql = "CREATE TABLE users (id SERIAL); CREATE INDEX idx_users ON users (id); DROP TABLE old_table;";
1635 let ops = extract_ddl_operations(sql);
1636 assert_eq!(ops.len(), 3);
1637 }
1638
1639 #[test]
1640 fn test_extract_truncate() {
1641 let sql = "TRUNCATE TABLE users;";
1642 let ops = extract_ddl_operations(sql);
1643 match &ops[0] {
1644 DdlOperation::TruncateTable { table } => assert_eq!(table, "users"),
1645 _ => panic!("Expected TruncateTable"),
1646 }
1647 }
1648
1649 #[test]
1650 fn test_extract_drop_column() {
1651 let sql = "ALTER TABLE users DROP COLUMN email;";
1652 let ops = extract_ddl_operations(sql);
1653 match &ops[0] {
1654 DdlOperation::AlterTableDropColumn { table, column } => {
1655 assert_eq!(table, "users");
1656 assert_eq!(column, "email");
1657 }
1658 _ => panic!("Expected AlterTableDropColumn"),
1659 }
1660 }
1661
1662 #[test]
1663 fn test_extract_alter_column() {
1664 let sql = "ALTER TABLE users ALTER COLUMN name TYPE text;";
1665 let ops = extract_ddl_operations(sql);
1666 match &ops[0] {
1667 DdlOperation::AlterTableAlterColumn { table, column } => {
1668 assert_eq!(table, "users");
1669 assert_eq!(column, "name");
1670 }
1671 _ => panic!("Expected AlterTableAlterColumn"),
1672 }
1673 }
1674
1675 #[test]
1676 fn test_extract_materialized_view() {
1677 let sql = "CREATE MATERIALIZED VIEW user_stats AS SELECT count(*) FROM users;";
1678 let ops = extract_ddl_operations(sql);
1679 match &ops[0] {
1680 DdlOperation::CreateView {
1681 name,
1682 is_materialized,
1683 } => {
1684 assert_eq!(name, "user_stats");
1685 assert!(is_materialized);
1686 }
1687 _ => panic!("Expected CreateView"),
1688 }
1689 }
1690
1691 #[test]
1692 fn test_block_comment_with_semicolons() {
1693 let sql = "/* comment; with; semicolons */ SELECT 1;";
1694 let stmts = split_statements(sql);
1695 assert_eq!(stmts.len(), 1);
1696 }
1697
1698 #[test]
1699 fn test_escaped_string_quotes() {
1700 let sql = "SELECT 'it''s; here'; SELECT 2;";
1701 let stmts = split_statements(sql);
1702 assert_eq!(stmts.len(), 2);
1703 }
1704
1705 #[test]
1706 fn test_split_respects_e_escape_strings() {
1707 let sql = r"SELECT E'hello\';world'; SELECT 2;";
1708 let stmts = split_statements(sql);
1709 assert_eq!(stmts.len(), 2);
1710 assert!(stmts[0].contains(r"E'hello\';world'"));
1711 }
1712
1713 #[test]
1714 fn test_split_e_string_with_backslash() {
1715 let sql = r"SELECT E'it\'s a test; really'; SELECT 1;";
1716 let stmts = split_statements(sql);
1717 assert_eq!(stmts.len(), 2);
1718 }
1719
1720 #[test]
1721 fn test_split_nested_block_comments() {
1722 let sql = "SELECT /* outer /* inner */ outer */ 1; SELECT 2;";
1723 let stmts = split_statements(sql);
1724 assert_eq!(stmts.len(), 2);
1725 assert_eq!(stmts[1], "SELECT 2");
1726 }
1727
1728 #[test]
1729 fn test_split_whitespace_only() {
1730 let stmts = split_statements(" \n\t ");
1731 assert!(stmts.is_empty());
1732 }
1733
1734 #[test]
1735 fn test_split_comment_only() {
1736 let stmts = split_statements("-- just a comment\n");
1737 assert_eq!(stmts.len(), 1);
1738 assert_eq!(stmts[0], "-- just a comment");
1739 }
1740
1741 #[test]
1742 fn test_split_mixed_e_and_regular_strings() {
1743 let sql = r"SELECT 'normal;string', E'escape\';string'; SELECT 2;";
1744 let stmts = split_statements(sql);
1745 assert_eq!(stmts.len(), 2);
1746 }
1747
1748 #[test]
1749 fn test_split_mysql_basic() {
1750 let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1751 let stmts = split_mysql_statements(sql);
1752 assert_eq!(stmts.len(), 2);
1753 assert!(stmts[0].contains("CREATE TABLE a"));
1754 assert!(stmts[1].contains("CREATE TABLE b"));
1755 }
1756
1757 #[test]
1758 fn test_split_mysql_respects_backticks_with_semicolons() {
1759 let sql = "CREATE TABLE `weird;name` (id INT); CREATE TABLE b (id INT);";
1761 let stmts = split_mysql_statements(sql);
1762 assert_eq!(stmts.len(), 2);
1763 assert!(stmts[0].contains("`weird;name`"));
1764 }
1765
1766 #[test]
1767 fn test_split_mysql_respects_string_literals_with_semicolons() {
1768 let sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c;d');";
1769 let stmts = split_mysql_statements(sql);
1770 assert_eq!(stmts.len(), 2);
1771 }
1772
1773 #[test]
1774 fn test_split_mysql_keeps_leading_comments_with_statement() {
1775 let sql = "-- header comment\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
1778 let stmts = split_mysql_statements(sql);
1779 assert_eq!(stmts.len(), 2);
1780 assert!(stmts[0].contains("CREATE TABLE a"));
1781 }
1782
1783 #[test]
1784 fn test_split_mysql_handles_block_comments() {
1785 let sql = "/* block ; comment */ CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1786 let stmts = split_mysql_statements(sql);
1787 assert_eq!(stmts.len(), 2);
1788 }
1789
1790 #[test]
1791 fn test_split_mysql_no_trailing_semicolon() {
1792 let sql = "CREATE TABLE a (id INT)";
1793 let stmts = split_mysql_statements(sql);
1794 assert_eq!(stmts.len(), 1);
1795 assert!(stmts[0].contains("CREATE TABLE a"));
1796 }
1797}