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
652 && is_kw(t, "ADD")
653 && let Some((clause, next)) = parse_one_add_column(stmt, &toks, i + 1)
654 {
655 clauses.push(clause);
656 i = next;
657 continue;
658 }
659 i += 1;
660 }
661
662 if clauses.is_empty() {
663 None
664 } else {
665 Some((table, clauses))
666 }
667}
668
669fn parse_one_add_column<'a>(
674 stmt: &'a str,
675 toks: &[Tok<'a>],
676 mut i: usize,
677) -> Option<(AddColumnClause, usize)> {
678 if kw_at(toks, i, "COLUMN") {
679 i += 1;
680 }
681
682 let mut if_not_exists = false;
683 if kw_at(toks, i, "IF") && kw_at(toks, i + 1, "NOT") && kw_at(toks, i + 2, "EXISTS") {
684 if_not_exists = true;
685 i += 3;
686 }
687
688 let col = toks.get(i)?;
689 if !is_identifier(col) {
690 return None;
691 }
692 if col.kind == TokKind::Word
694 && TABLE_CONSTRAINT_KEYWORDS
695 .iter()
696 .any(|k| col.text.eq_ignore_ascii_case(k))
697 {
698 return None;
699 }
700 let column = col.text.to_string();
701 let column_offset = col.start;
702 i += 1;
703
704 let type_first = i;
707 let mut depth = 0usize;
708 while i < toks.len() {
709 let t = &toks[i];
710 if t.kind == TokKind::Punct {
711 match t.text {
712 "(" => depth += 1,
713 ")" => {
714 if depth == 0 {
715 break;
716 }
717 depth -= 1;
718 }
719 "," if depth == 0 => break,
720 _ => {}
721 }
722 i += 1;
723 continue;
724 }
725 if depth == 0
726 && t.kind == TokKind::Word
727 && COLUMN_CONSTRAINT_KEYWORDS
728 .iter()
729 .any(|k| t.text.eq_ignore_ascii_case(k))
730 {
731 break;
732 }
733 i += 1;
734 }
735 let data_type = if i > type_first {
736 normalize_whitespace(&stmt[toks[type_first].start..toks[i - 1].end])
737 } else {
738 "unknown".to_string()
739 };
740
741 let mut is_not_null = false;
743 let mut default_expr = None;
744 let mut depth = 0usize;
745 while i < toks.len() {
746 let t = &toks[i];
747 if t.kind == TokKind::Punct {
748 match t.text {
749 "(" => depth += 1,
750 ")" => {
751 if depth == 0 {
752 break;
753 }
754 depth -= 1;
755 }
756 "," if depth == 0 => {
757 i += 1;
758 break;
759 }
760 _ => {}
761 }
762 i += 1;
763 continue;
764 }
765 if depth == 0 && t.kind == TokKind::Word {
766 if is_kw(t, "NOT") && kw_at(toks, i + 1, "NULL") {
767 is_not_null = true;
768 i += 2;
769 continue;
770 }
771 if is_kw(t, "DEFAULT") {
772 let (expr, next) = read_default_expr(stmt, toks, i + 1);
773 default_expr = Some(expr);
774 i = next;
775 continue;
776 }
777 }
778 i += 1;
779 }
780
781 Some((
782 AddColumnClause {
783 column,
784 column_offset,
785 data_type,
786 if_not_exists,
787 is_not_null,
788 has_default: default_expr.is_some(),
789 default_expr,
790 },
791 i,
792 ))
793}
794
795fn read_default_expr(stmt: &str, toks: &[Tok<'_>], start: usize) -> (String, usize) {
801 let mut i = start;
802 let mut depth = 0usize;
803 while i < toks.len() {
804 let t = &toks[i];
805 if t.kind == TokKind::Punct {
806 match t.text {
807 "(" => depth += 1,
808 ")" => {
809 if depth == 0 {
810 break;
811 }
812 depth -= 1;
813 }
814 "," if depth == 0 => break,
815 _ => {}
816 }
817 i += 1;
818 continue;
819 }
820 if depth == 0
821 && i > start
822 && t.kind == TokKind::Word
823 && COLUMN_CONSTRAINT_KEYWORDS
824 .iter()
825 .any(|k| t.text.eq_ignore_ascii_case(k))
826 {
827 break;
828 }
829 i += 1;
830 }
831
832 let expr = if i > start {
833 normalize_whitespace(&stmt[toks[start].start..toks[i - 1].end])
834 } else {
835 String::new()
836 };
837 (expr, i)
838}
839
840fn normalize_whitespace(s: &str) -> String {
842 s.split_whitespace().collect::<Vec<_>>().join(" ")
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
850enum TokKind {
851 Word,
853 Ident,
855 Literal,
857 Punct,
859}
860
861#[derive(Debug, Clone, Copy)]
862struct Tok<'a> {
863 kind: TokKind,
864 text: &'a str,
865 start: usize,
866 end: usize,
867}
868
869fn is_kw(tok: &Tok<'_>, kw: &str) -> bool {
870 tok.kind == TokKind::Word && tok.text.eq_ignore_ascii_case(kw)
871}
872
873fn kw_at(toks: &[Tok<'_>], i: usize, kw: &str) -> bool {
874 toks.get(i).is_some_and(|t| is_kw(t, kw))
875}
876
877fn is_identifier(tok: &Tok<'_>) -> bool {
879 match tok.kind {
880 TokKind::Ident => true,
881 TokKind::Word => tok
882 .text
883 .starts_with(|c: char| c.is_alphabetic() || c == '_'),
884 _ => false,
885 }
886}
887
888fn tokenize(sql: &str) -> Vec<Tok<'_>> {
890 let bytes = sql.as_bytes();
891 let len = bytes.len();
892 let mut toks = Vec::new();
893 let mut i = 0;
894
895 while i < len {
896 let c = bytes[i];
897 if c.is_ascii_whitespace() {
898 i += 1;
899 continue;
900 }
901 if let Some(j) = skip_comment(bytes, i) {
902 i = j;
903 continue;
904 }
905 if c == b'\'' {
906 let j = skip_quoted(sql, i).unwrap_or(len);
907 toks.push(Tok {
908 kind: TokKind::Literal,
909 text: &sql[i..j],
910 start: i,
911 end: j,
912 });
913 i = j;
914 continue;
915 }
916 if c == b'"' || c == b'`' {
917 let mut j = i + 1;
918 while j < len {
919 if bytes[j] == c {
920 if j + 1 < len && bytes[j + 1] == c {
921 j += 2;
922 continue;
923 }
924 break;
925 }
926 j += 1;
927 }
928 let inner_end = j.min(len);
929 let end = (j + 1).min(len);
930 toks.push(Tok {
931 kind: TokKind::Ident,
932 text: &sql[i + 1..inner_end],
933 start: i,
934 end,
935 });
936 i = end;
937 continue;
938 }
939 if c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 {
940 let mut j = i;
941 while j < len
942 && (bytes[j].is_ascii_alphanumeric()
943 || bytes[j] == b'_'
944 || bytes[j] == b'$'
945 || bytes[j] >= 0x80)
946 {
947 j += 1;
948 }
949 toks.push(Tok {
950 kind: TokKind::Word,
951 text: &sql[i..j],
952 start: i,
953 end: j,
954 });
955 i = j;
956 continue;
957 }
958 toks.push(Tok {
959 kind: TokKind::Punct,
960 text: &sql[i..i + 1],
961 start: i,
962 end: i + 1,
963 });
964 i += 1;
965 }
966
967 toks
968}
969
970pub fn split_statements(sql: &str) -> Vec<&str> {
973 statement_ranges(sql)
974 .into_iter()
975 .map(|(s, e)| &sql[s..e])
976 .collect()
977}
978
979fn statement_ranges(sql: &str) -> Vec<(usize, usize)> {
982 let bytes = sql.as_bytes();
983 let len = bytes.len();
984 let mut ranges = Vec::new();
985 let mut start = 0;
986 let mut i = 0;
987
988 while i < len {
989 if let Some(j) = skip_comment(bytes, i) {
990 i = j;
991 continue;
992 }
993 if let Some(j) = skip_quoted(sql, i) {
994 i = j;
995 continue;
996 }
997 if bytes[i] == b';' {
998 if let Some(r) = trim_range(sql, start, i) {
999 ranges.push(r);
1000 }
1001 i += 1;
1002 start = i;
1003 continue;
1004 }
1005 i += 1;
1006 }
1007
1008 if let Some(r) = trim_range(sql, start, len) {
1010 ranges.push(r);
1011 }
1012
1013 ranges
1014}
1015
1016fn trim_range(sql: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1019 let slice = &sql[start..end];
1020 if slice.trim().is_empty() {
1021 return None;
1022 }
1023 let lead = slice.len() - slice.trim_start().len();
1024 let trail = slice.len() - slice.trim_end().len();
1025 Some((start + lead, end - trail))
1026}
1027
1028fn skip_comment(bytes: &[u8], i: usize) -> Option<usize> {
1033 let len = bytes.len();
1034 if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1035 let mut j = i + 2;
1036 while j < len && bytes[j] != b'\n' {
1037 j += 1;
1038 }
1039 return Some(j);
1040 }
1041 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1042 let mut j = i + 2;
1043 let mut depth = 1usize;
1044 while j < len && depth > 0 {
1045 if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
1046 depth += 1;
1047 j += 2;
1048 } else if j + 1 < len && bytes[j] == b'*' && bytes[j + 1] == b'/' {
1049 depth -= 1;
1050 j += 2;
1051 } else {
1052 j += 1;
1053 }
1054 }
1055 return Some(j.min(len));
1056 }
1057 None
1058}
1059
1060fn skip_quoted(sql: &str, i: usize) -> Option<usize> {
1066 let bytes = sql.as_bytes();
1067 let len = bytes.len();
1068 match bytes[i] {
1069 b'\'' => {
1070 let is_escape_string = i > 0
1072 && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
1073 && (i < 2 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_'));
1074 let mut j = i + 1;
1075 while j < len {
1076 if is_escape_string && bytes[j] == b'\\' {
1077 j += 2;
1078 continue;
1079 }
1080 if bytes[j] == b'\'' {
1081 if j + 1 < len && bytes[j + 1] == b'\'' {
1082 j += 2; } else {
1084 j += 1;
1085 break;
1086 }
1087 } else {
1088 j += 1;
1089 }
1090 }
1091 Some(j.min(len))
1092 }
1093 q @ (b'"' | b'`') => {
1094 let mut j = i + 1;
1095 while j < len {
1096 if bytes[j] == q {
1097 if j + 1 < len && bytes[j + 1] == q {
1098 j += 2; continue;
1100 }
1101 j += 1;
1102 break;
1103 }
1104 j += 1;
1105 }
1106 Some(j.min(len))
1107 }
1108 b'$' => {
1110 let tag_start = i;
1111 let mut j = i + 1;
1112 while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
1113 j += 1;
1114 }
1115 if j < len && bytes[j] == b'$' {
1116 let tag = &sql[tag_start..=j];
1117 j += 1;
1118 while j < len {
1119 if bytes[j] == b'$' && sql[j..].starts_with(tag) {
1120 j += tag.len();
1121 break;
1122 }
1123 j += 1;
1124 }
1125 }
1126 Some(j.min(len))
1127 }
1128 _ => None,
1129 }
1130}
1131
1132pub fn strip_comments(sql: &str) -> String {
1139 let bytes = sql.as_bytes();
1140 let len = bytes.len();
1141 let mut out = bytes.to_vec();
1142 let mut i = 0;
1143
1144 while i < len {
1145 if let Some(j) = skip_comment(bytes, i) {
1146 for b in &mut out[i..j] {
1147 if *b != b'\n' {
1148 *b = b' ';
1149 }
1150 }
1151 i = j;
1152 continue;
1153 }
1154 if let Some(j) = skip_quoted(sql, i) {
1155 i = j;
1156 continue;
1157 }
1158 i += 1;
1159 }
1160
1161 String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1164}
1165
1166pub fn line_number_at(sql: &str, offset: usize) -> usize {
1168 sql[..offset.min(sql.len())]
1169 .bytes()
1170 .filter(|b| *b == b'\n')
1171 .count()
1172 + 1
1173}
1174
1175pub fn split_mysql_statements(sql: &str) -> Vec<String> {
1186 let bytes = sql.as_bytes();
1187 let len = bytes.len();
1188 let mut out = Vec::new();
1189 let mut start = 0;
1190 let mut i = 0;
1191 while i < len {
1192 let c = bytes[i];
1193 if c == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1195 while i < len && bytes[i] != b'\n' {
1196 i += 1;
1197 }
1198 continue;
1199 }
1200 if c == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1202 i += 2;
1203 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1204 i += 1;
1205 }
1206 i = (i + 2).min(len);
1207 continue;
1208 }
1209 if c == b'\'' {
1211 i += 1;
1212 while i < len && bytes[i] != b'\'' {
1213 if bytes[i] == b'\\' && i + 1 < len {
1214 i += 2;
1215 } else {
1216 i += 1;
1217 }
1218 }
1219 i += 1;
1220 continue;
1221 }
1222 if c == b'"' {
1224 i += 1;
1225 while i < len && bytes[i] != b'"' {
1226 if bytes[i] == b'\\' && i + 1 < len {
1227 i += 2;
1228 } else {
1229 i += 1;
1230 }
1231 }
1232 i += 1;
1233 continue;
1234 }
1235 if c == b'`' {
1237 i += 1;
1238 while i < len && bytes[i] != b'`' {
1239 i += 1;
1240 }
1241 i += 1;
1242 continue;
1243 }
1244 if c == b';' {
1246 push_mysql_statement(&mut out, &sql[start..i]);
1247 i += 1;
1248 start = i;
1249 continue;
1250 }
1251 i += 1;
1252 }
1253 push_mysql_statement(&mut out, &sql[start..]);
1254 out
1255}
1256
1257fn push_mysql_statement(out: &mut Vec<String>, candidate: &str) {
1265 let trimmed = candidate.trim();
1266 if trimmed.is_empty() {
1267 return;
1268 }
1269 if strip_comments(trimmed).trim().is_empty() {
1270 return;
1271 }
1272 out.push(trimmed.to_string());
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278
1279 #[test]
1280 fn test_split_simple_statements() {
1281 let sql = "SELECT 1; SELECT 2;";
1282 let stmts = split_statements(sql);
1283 assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
1284 }
1285
1286 #[test]
1287 fn test_split_respects_string_literals() {
1288 let sql = "SELECT 'hello;world'; SELECT 2;";
1289 let stmts = split_statements(sql);
1290 assert_eq!(stmts, vec!["SELECT 'hello;world'", "SELECT 2"]);
1291 }
1292
1293 #[test]
1294 fn test_split_respects_dollar_quoting() {
1295 let sql =
1296 "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql; SELECT 1;";
1297 let stmts = split_statements(sql);
1298 assert_eq!(stmts.len(), 2);
1299 assert!(stmts[0].contains("BEGIN; END;"));
1300 }
1301
1302 #[test]
1303 fn test_split_respects_tagged_dollar_quoting() {
1304 let sql = "CREATE FUNCTION foo() RETURNS void AS $body$ BEGIN; END; $body$ LANGUAGE plpgsql; SELECT 1;";
1305 let stmts = split_statements(sql);
1306 assert_eq!(stmts.len(), 2);
1307 assert!(stmts[0].contains("BEGIN; END;"));
1308 }
1309
1310 #[test]
1311 fn test_split_respects_comments() {
1312 let sql = "-- This is a comment with ; semicolon\nSELECT 1;";
1313 let stmts = split_statements(sql);
1314 assert_eq!(stmts.len(), 1);
1315 }
1316
1317 #[test]
1318 fn test_split_no_trailing_semicolon() {
1319 let sql = "SELECT 1";
1320 let stmts = split_statements(sql);
1321 assert_eq!(stmts, vec!["SELECT 1"]);
1322 }
1323
1324 #[test]
1325 fn test_extract_create_table() {
1326 let sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);";
1327 let ops = extract_ddl_operations(sql);
1328 assert_eq!(ops.len(), 1);
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_create_table_if_not_exists() {
1343 let sql = "CREATE TABLE IF NOT EXISTS users (id SERIAL);";
1344 let ops = extract_ddl_operations(sql);
1345 match &ops[0] {
1346 DdlOperation::CreateTable {
1347 table,
1348 if_not_exists,
1349 } => {
1350 assert_eq!(table, "users");
1351 assert!(if_not_exists);
1352 }
1353 _ => panic!("Expected CreateTable"),
1354 }
1355 }
1356
1357 #[test]
1358 fn test_extract_add_column() {
1359 let sql = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';";
1360 let ops = extract_ddl_operations(sql);
1361 match &ops[0] {
1362 DdlOperation::AlterTableAddColumn {
1363 table,
1364 column,
1365 is_not_null,
1366 has_default,
1367 ..
1368 } => {
1369 assert_eq!(table, "users");
1370 assert_eq!(column, "email");
1371 assert!(is_not_null);
1372 assert!(has_default);
1373 }
1374 _ => panic!("Expected AlterTableAddColumn"),
1375 }
1376 }
1377
1378 fn add_column(sql: &str) -> DdlOperation {
1380 let ops = extract_ddl_operations(sql);
1381 assert_eq!(ops.len(), 1, "expected exactly one op, got {:?}", ops);
1382 ops.into_iter().next().unwrap()
1383 }
1384
1385 #[test]
1386 fn test_add_column_if_not_exists_names_the_column() {
1387 match add_column(
1388 "ALTER TABLE dicom.reid_shares ADD COLUMN IF NOT EXISTS threshold smallint;",
1389 ) {
1390 DdlOperation::AlterTableAddColumn {
1391 table,
1392 column,
1393 data_type,
1394 is_not_null,
1395 has_default,
1396 if_not_exists,
1397 default_expr,
1398 } => {
1399 assert_eq!(table, "reid_shares");
1400 assert_eq!(column, "threshold");
1401 assert_eq!(data_type, "smallint");
1402 assert!(default_expr.is_none());
1403 assert!(!is_not_null);
1404 assert!(!has_default);
1405 assert!(if_not_exists);
1406 }
1407 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1408 }
1409 }
1410
1411 #[test]
1412 fn test_add_column_ignores_not_null_in_comments() {
1413 let sql = "-- Every ceremony writes the threshold NOT NULL.\n\
1414 ALTER TABLE dicom.reid_shares\n \
1415 ADD COLUMN IF NOT EXISTS threshold smallint\n \
1416 CHECK (threshold IS NULL OR threshold BETWEEN 1 AND 255);";
1417 match add_column(sql) {
1418 DdlOperation::AlterTableAddColumn {
1419 column,
1420 is_not_null,
1421 ..
1422 } => {
1423 assert_eq!(column, "threshold");
1424 assert!(!is_not_null, "NOT NULL came from a comment");
1425 }
1426 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1427 }
1428 }
1429
1430 #[test]
1431 fn test_add_column_ignores_not_null_inside_check() {
1432 match add_column("ALTER TABLE t ADD COLUMN c text CHECK (c IS NOT NULL);") {
1433 DdlOperation::AlterTableAddColumn { is_not_null, .. } => assert!(!is_not_null),
1434 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1435 }
1436 }
1437
1438 #[test]
1439 fn test_add_column_ignores_keywords_inside_string_literals() {
1440 match add_column("ALTER TABLE t ADD COLUMN c text DEFAULT 'NOT NULL';") {
1441 DdlOperation::AlterTableAddColumn {
1442 is_not_null,
1443 has_default,
1444 ..
1445 } => {
1446 assert!(!is_not_null);
1447 assert!(has_default);
1448 }
1449 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1450 }
1451 }
1452
1453 #[test]
1454 fn test_add_column_quoted_and_parenthesised_type() {
1455 match add_column(
1456 r#"ALTER TABLE "my schema"."my table" ADD "my col" numeric(10,2) NOT NULL;"#,
1457 ) {
1458 DdlOperation::AlterTableAddColumn {
1459 table,
1460 column,
1461 data_type,
1462 is_not_null,
1463 ..
1464 } => {
1465 assert_eq!(table, "my table");
1466 assert_eq!(column, "my col");
1467 assert_eq!(data_type, "numeric(10,2)");
1468 assert!(is_not_null);
1469 }
1470 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1471 }
1472 }
1473
1474 #[test]
1475 fn test_add_column_multiword_type() {
1476 match add_column("ALTER TABLE t ADD COLUMN c timestamp with time zone NOT NULL;") {
1477 DdlOperation::AlterTableAddColumn {
1478 data_type,
1479 is_not_null,
1480 ..
1481 } => {
1482 assert_eq!(data_type, "timestamp with time zone");
1483 assert!(is_not_null);
1484 }
1485 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1486 }
1487 }
1488
1489 #[test]
1490 fn test_add_multiple_columns_in_one_statement() {
1491 let ops = extract_ddl_operations(
1492 "ALTER TABLE t ADD COLUMN a int, ADD COLUMN IF NOT EXISTS b text NOT NULL;",
1493 );
1494 assert_eq!(ops.len(), 2);
1495 match (&ops[0], &ops[1]) {
1496 (
1497 DdlOperation::AlterTableAddColumn {
1498 column: c1,
1499 is_not_null: n1,
1500 ..
1501 },
1502 DdlOperation::AlterTableAddColumn {
1503 column: c2,
1504 is_not_null: n2,
1505 if_not_exists,
1506 ..
1507 },
1508 ) => {
1509 assert_eq!(c1, "a");
1510 assert!(!n1);
1511 assert_eq!(c2, "b");
1512 assert!(n2);
1513 assert!(if_not_exists);
1514 }
1515 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1516 }
1517 }
1518
1519 #[test]
1520 fn test_add_column_captures_default_expression() {
1521 for (sql, expected) in [
1522 (
1523 "ALTER TABLE t ADD COLUMN c timestamptz DEFAULT now();",
1524 "now()",
1525 ),
1526 ("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 5;", "5"),
1527 (
1528 "ALTER TABLE t ADD COLUMN c text DEFAULT 'x' NOT NULL;",
1529 "'x'",
1530 ),
1531 ("ALTER TABLE t ADD COLUMN c text DEFAULT NULL;", "NULL"),
1532 (
1533 "ALTER TABLE t ADD COLUMN c text[] DEFAULT '{}' CHECK (c IS NOT NULL);",
1534 "'{}'",
1535 ),
1536 ] {
1537 match add_column(sql) {
1538 DdlOperation::AlterTableAddColumn { default_expr, .. } => {
1539 assert_eq!(default_expr.as_deref(), Some(expected), "for {}", sql)
1540 }
1541 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1542 }
1543 }
1544 }
1545
1546 #[test]
1547 fn test_add_column_default_is_per_clause() {
1548 let ops = extract_ddl_operations(
1549 "ALTER TABLE t ADD COLUMN a text[] DEFAULT '{}', ADD COLUMN b timestamptz NOT NULL DEFAULT now();",
1550 );
1551 assert_eq!(ops.len(), 2);
1552 match (&ops[0], &ops[1]) {
1553 (
1554 DdlOperation::AlterTableAddColumn {
1555 default_expr: a, ..
1556 },
1557 DdlOperation::AlterTableAddColumn {
1558 default_expr: b, ..
1559 },
1560 ) => {
1561 assert_eq!(a.as_deref(), Some("'{}'"));
1562 assert_eq!(b.as_deref(), Some("now()"));
1563 }
1564 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1565 }
1566 }
1567
1568 #[test]
1569 fn test_add_constraint_is_not_parsed_as_a_column() {
1570 let ops = extract_ddl_operations("ALTER TABLE t ADD CONSTRAINT t_pk PRIMARY KEY (id);");
1571 assert!(matches!(ops[0], DdlOperation::AddConstraint { .. }));
1572 }
1573
1574 #[test]
1575 fn test_located_ops_point_at_the_column() {
1576 let sql =
1577 "-- comment with NOT NULL\n-- another\nALTER TABLE t\n ADD COLUMN c int NOT NULL;";
1578 let located = extract_ddl_operations_located(sql);
1579 assert_eq!(located.len(), 1);
1580 assert_eq!(line_number_at(sql, located[0].start), 3);
1582 assert_eq!(line_number_at(sql, located[0].focus), 4);
1583 assert_eq!(&sql[located[0].focus..located[0].focus + 1], "c");
1584 }
1585
1586 #[test]
1587 fn test_strip_comments_preserves_offsets_and_lines() {
1588 let sql = "-- NOT NULL\nSELECT 1; /* NOT NULL */\nSELECT 'not -- a comment';";
1589 let stripped = strip_comments(sql);
1590 assert_eq!(stripped.len(), sql.len());
1591 assert_eq!(stripped.lines().count(), sql.lines().count());
1592 assert!(!stripped.to_uppercase().contains("NOT NULL"));
1593 assert!(stripped.contains("'not -- a comment'"));
1594 }
1595
1596 #[test]
1597 fn test_line_number_at_is_one_based() {
1598 let sql = "a\nb\nc";
1599 assert_eq!(line_number_at(sql, 0), 1);
1600 assert_eq!(line_number_at(sql, 2), 2);
1601 assert_eq!(line_number_at(sql, 4), 3);
1602 }
1603
1604 #[test]
1605 fn test_extract_create_index() {
1606 let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email);";
1607 let ops = extract_ddl_operations(sql);
1608 match &ops[0] {
1609 DdlOperation::CreateIndex {
1610 name,
1611 table,
1612 is_concurrent,
1613 is_unique,
1614 } => {
1615 assert_eq!(name, "idx_users_email");
1616 assert_eq!(table, "users");
1617 assert!(is_concurrent);
1618 assert!(is_unique);
1619 }
1620 _ => panic!("Expected CreateIndex"),
1621 }
1622 }
1623
1624 #[test]
1625 fn test_extract_create_function() {
1626 let sql = "CREATE OR REPLACE FUNCTION my_func() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;";
1627 let ops = extract_ddl_operations(sql);
1628 match &ops[0] {
1629 DdlOperation::CreateFunction { name } => {
1630 assert_eq!(name, "my_func");
1631 }
1632 _ => panic!("Expected CreateFunction, got {:?}", ops[0]),
1633 }
1634 }
1635
1636 #[test]
1637 fn test_extract_create_enum() {
1638 let sql = "CREATE TYPE mood AS ENUM ('happy', 'sad');";
1639 let ops = extract_ddl_operations(sql);
1640 match &ops[0] {
1641 DdlOperation::CreateEnum { name } => {
1642 assert_eq!(name, "mood");
1643 }
1644 _ => panic!("Expected CreateEnum"),
1645 }
1646 }
1647
1648 #[test]
1649 fn test_extract_multiple() {
1650 let sql = "CREATE TABLE users (id SERIAL); CREATE INDEX idx_users ON users (id); DROP TABLE old_table;";
1651 let ops = extract_ddl_operations(sql);
1652 assert_eq!(ops.len(), 3);
1653 }
1654
1655 #[test]
1656 fn test_extract_truncate() {
1657 let sql = "TRUNCATE TABLE users;";
1658 let ops = extract_ddl_operations(sql);
1659 match &ops[0] {
1660 DdlOperation::TruncateTable { table } => assert_eq!(table, "users"),
1661 _ => panic!("Expected TruncateTable"),
1662 }
1663 }
1664
1665 #[test]
1666 fn test_extract_drop_column() {
1667 let sql = "ALTER TABLE users DROP COLUMN email;";
1668 let ops = extract_ddl_operations(sql);
1669 match &ops[0] {
1670 DdlOperation::AlterTableDropColumn { table, column } => {
1671 assert_eq!(table, "users");
1672 assert_eq!(column, "email");
1673 }
1674 _ => panic!("Expected AlterTableDropColumn"),
1675 }
1676 }
1677
1678 #[test]
1679 fn test_extract_alter_column() {
1680 let sql = "ALTER TABLE users ALTER COLUMN name TYPE text;";
1681 let ops = extract_ddl_operations(sql);
1682 match &ops[0] {
1683 DdlOperation::AlterTableAlterColumn { table, column } => {
1684 assert_eq!(table, "users");
1685 assert_eq!(column, "name");
1686 }
1687 _ => panic!("Expected AlterTableAlterColumn"),
1688 }
1689 }
1690
1691 #[test]
1692 fn test_extract_materialized_view() {
1693 let sql = "CREATE MATERIALIZED VIEW user_stats AS SELECT count(*) FROM users;";
1694 let ops = extract_ddl_operations(sql);
1695 match &ops[0] {
1696 DdlOperation::CreateView {
1697 name,
1698 is_materialized,
1699 } => {
1700 assert_eq!(name, "user_stats");
1701 assert!(is_materialized);
1702 }
1703 _ => panic!("Expected CreateView"),
1704 }
1705 }
1706
1707 #[test]
1708 fn test_block_comment_with_semicolons() {
1709 let sql = "/* comment; with; semicolons */ SELECT 1;";
1710 let stmts = split_statements(sql);
1711 assert_eq!(stmts.len(), 1);
1712 }
1713
1714 #[test]
1715 fn test_escaped_string_quotes() {
1716 let sql = "SELECT 'it''s; here'; SELECT 2;";
1717 let stmts = split_statements(sql);
1718 assert_eq!(stmts.len(), 2);
1719 }
1720
1721 #[test]
1722 fn test_split_respects_e_escape_strings() {
1723 let sql = r"SELECT E'hello\';world'; SELECT 2;";
1724 let stmts = split_statements(sql);
1725 assert_eq!(stmts.len(), 2);
1726 assert!(stmts[0].contains(r"E'hello\';world'"));
1727 }
1728
1729 #[test]
1730 fn test_split_e_string_with_backslash() {
1731 let sql = r"SELECT E'it\'s a test; really'; SELECT 1;";
1732 let stmts = split_statements(sql);
1733 assert_eq!(stmts.len(), 2);
1734 }
1735
1736 #[test]
1737 fn test_split_nested_block_comments() {
1738 let sql = "SELECT /* outer /* inner */ outer */ 1; SELECT 2;";
1739 let stmts = split_statements(sql);
1740 assert_eq!(stmts.len(), 2);
1741 assert_eq!(stmts[1], "SELECT 2");
1742 }
1743
1744 #[test]
1745 fn test_split_whitespace_only() {
1746 let stmts = split_statements(" \n\t ");
1747 assert!(stmts.is_empty());
1748 }
1749
1750 #[test]
1751 fn test_split_comment_only() {
1752 let stmts = split_statements("-- just a comment\n");
1753 assert_eq!(stmts.len(), 1);
1754 assert_eq!(stmts[0], "-- just a comment");
1755 }
1756
1757 #[test]
1758 fn test_split_mixed_e_and_regular_strings() {
1759 let sql = r"SELECT 'normal;string', E'escape\';string'; SELECT 2;";
1760 let stmts = split_statements(sql);
1761 assert_eq!(stmts.len(), 2);
1762 }
1763
1764 #[test]
1765 fn test_split_mysql_basic() {
1766 let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1767 let stmts = split_mysql_statements(sql);
1768 assert_eq!(stmts.len(), 2);
1769 assert!(stmts[0].contains("CREATE TABLE a"));
1770 assert!(stmts[1].contains("CREATE TABLE b"));
1771 }
1772
1773 #[test]
1774 fn test_split_mysql_respects_backticks_with_semicolons() {
1775 let sql = "CREATE TABLE `weird;name` (id INT); CREATE TABLE b (id INT);";
1777 let stmts = split_mysql_statements(sql);
1778 assert_eq!(stmts.len(), 2);
1779 assert!(stmts[0].contains("`weird;name`"));
1780 }
1781
1782 #[test]
1783 fn test_split_mysql_respects_string_literals_with_semicolons() {
1784 let sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c;d');";
1785 let stmts = split_mysql_statements(sql);
1786 assert_eq!(stmts.len(), 2);
1787 }
1788
1789 #[test]
1790 fn test_split_mysql_keeps_leading_comments_with_statement() {
1791 let sql = "-- header comment\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
1794 let stmts = split_mysql_statements(sql);
1795 assert_eq!(stmts.len(), 2);
1796 assert!(stmts[0].contains("CREATE TABLE a"));
1797 }
1798
1799 #[test]
1800 fn test_split_mysql_handles_block_comments() {
1801 let sql = "/* block ; comment */ CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1802 let stmts = split_mysql_statements(sql);
1803 assert_eq!(stmts.len(), 2);
1804 }
1805
1806 #[test]
1807 fn test_split_mysql_no_trailing_semicolon() {
1808 let sql = "CREATE TABLE a (id INT)";
1809 let stmts = split_mysql_statements(sql);
1810 assert_eq!(stmts.len(), 1);
1811 assert!(stmts[0].contains("CREATE TABLE a"));
1812 }
1813
1814 #[test]
1815 fn test_split_mysql_drops_trailing_comment_only_statement() {
1816 let sql = "CREATE TABLE t (id INT);\n-- done\n";
1819 assert_eq!(
1820 split_mysql_statements(sql),
1821 vec!["CREATE TABLE t (id INT)".to_string()]
1822 );
1823 }
1824
1825 #[test]
1826 fn test_split_mysql_drops_empty_statements() {
1827 let sql = "SELECT 1;; SELECT 2;";
1828 assert_eq!(
1829 split_mysql_statements(sql),
1830 vec!["SELECT 1".to_string(), "SELECT 2".to_string()]
1831 );
1832 }
1833
1834 #[test]
1835 fn test_split_mysql_trims_every_statement() {
1836 let sql = "SELECT 1;\n SELECT 2 ;\n";
1837 assert_eq!(
1838 split_mysql_statements(sql),
1839 vec!["SELECT 1".to_string(), "SELECT 2".to_string()]
1840 );
1841 }
1842
1843 #[test]
1844 fn test_split_mysql_drops_block_comment_only_statement() {
1845 let sql = "SELECT 1; /* just a note */ ;";
1846 assert_eq!(split_mysql_statements(sql), vec!["SELECT 1".to_string()]);
1847 }
1848
1849 #[test]
1850 fn test_split_mysql_keeps_statement_with_leading_comment() {
1851 let sql = "-- set up\nCREATE TABLE t (id INT);";
1852 assert_eq!(
1853 split_mysql_statements(sql),
1854 vec!["-- set up\nCREATE TABLE t (id INT)".to_string()]
1855 );
1856 }
1857}