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);
333 let blanked = blank_string_literals(&stripped);
334 let mut ops = Vec::new();
335
336 for (start, end) in statement_ranges(&blanked) {
337 let stmt = &blanked[start..end];
338 let parsed = parse_statement_ops(stmt, &stripped[start..end]);
339 if parsed.is_empty() {
340 let raw = &sql[start..end];
343 let preview: String = raw.chars().take(80).collect();
344 let preview = if raw.len() > 80 {
345 format!("{}...", preview)
346 } else {
347 preview
348 };
349 ops.push(LocatedDdl {
350 op: DdlOperation::Other {
351 statement_preview: preview,
352 },
353 start,
354 end,
355 focus: start,
356 });
357 continue;
358 }
359 for (op, offset) in parsed {
360 ops.push(LocatedDdl {
361 op,
362 start,
363 end,
364 focus: start + offset,
365 });
366 }
367 }
368
369 ops
370}
371
372fn parse_statement_ops(stmt: &str, stmt_with_literals: &str) -> Vec<(DdlOperation, usize)> {
383 if let Some(caps) = ADD_CONSTRAINT_RE.captures(stmt) {
387 let table = caps.get(2).unwrap().as_str().to_string();
388 let constraint_type = caps.get(3).unwrap().as_str().to_uppercase();
389 return vec![(
390 DdlOperation::AddConstraint {
391 table,
392 constraint_type,
393 },
394 caps.get(0).unwrap().start(),
395 )];
396 }
397
398 if let Some(caps) = DROP_CONSTRAINT_RE.captures(stmt) {
400 let table = caps.get(2).unwrap().as_str().to_string();
401 let name = caps.get(3).unwrap().as_str().to_string();
402 return vec![(
403 DdlOperation::DropConstraint { table, name },
404 caps.get(0).unwrap().start(),
405 )];
406 }
407
408 if let Some(caps) = ALTER_TABLE_ALTER_COLUMN_RE.captures(stmt) {
410 let table = caps.get(2).unwrap().as_str().to_string();
411 let column = caps.get(3).unwrap().as_str().to_string();
412 return vec![(
413 DdlOperation::AlterTableAlterColumn { table, column },
414 caps.get(0).unwrap().start(),
415 )];
416 }
417
418 if let Some(caps) = ALTER_TABLE_DROP_COLUMN_RE.captures(stmt) {
420 let table = caps.get(2).unwrap().as_str().to_string();
421 let column = caps.get(3).unwrap().as_str().to_string();
422 return vec![(
423 DdlOperation::AlterTableDropColumn { table, column },
424 caps.get(0).unwrap().start(),
425 )];
426 }
427
428 if let Some((table, clauses)) = parse_add_columns(stmt_with_literals) {
430 return clauses
431 .into_iter()
432 .map(|c| {
433 (
434 DdlOperation::AlterTableAddColumn {
435 table: table.clone(),
436 column: c.column,
437 data_type: c.data_type,
438 has_default: c.has_default,
439 is_not_null: c.is_not_null,
440 if_not_exists: c.if_not_exists,
441 default_expr: c.default_expr,
442 },
443 c.column_offset,
444 )
445 })
446 .collect();
447 }
448
449 if let Some(caps) = CREATE_TABLE_RE.captures(stmt) {
451 let if_not_exists = caps.get(1).is_some();
452 let table = caps.get(3).unwrap().as_str().to_string();
453 return vec![(
454 DdlOperation::CreateTable {
455 table,
456 if_not_exists,
457 },
458 caps.get(0).unwrap().start(),
459 )];
460 }
461
462 if let Some(caps) = DROP_TABLE_RE.captures(stmt) {
464 let table = caps.get(2).unwrap().as_str().to_string();
465 return vec![(
466 DdlOperation::DropTable { table },
467 caps.get(0).unwrap().start(),
468 )];
469 }
470
471 if let Some(caps) = CREATE_INDEX_RE.captures(stmt) {
473 let is_unique = caps.get(1).is_some();
474 let is_concurrent = caps.get(2).is_some();
475 let name = caps.get(3).unwrap().as_str().to_string();
476 let table = caps.get(5).unwrap().as_str().to_string();
477 return vec![(
478 DdlOperation::CreateIndex {
479 name,
480 table,
481 is_concurrent,
482 is_unique,
483 },
484 caps.get(0).unwrap().start(),
485 )];
486 }
487
488 if let Some(caps) = DROP_INDEX_RE.captures(stmt) {
490 let name = caps.get(2).unwrap().as_str().to_string();
491 return vec![(
492 DdlOperation::DropIndex { name },
493 caps.get(0).unwrap().start(),
494 )];
495 }
496
497 if let Some(caps) = CREATE_VIEW_RE.captures(stmt) {
499 let is_materialized = caps.get(1).is_some();
500 let name = caps.get(3).unwrap().as_str().to_string();
501 return vec![(
502 DdlOperation::CreateView {
503 name,
504 is_materialized,
505 },
506 caps.get(0).unwrap().start(),
507 )];
508 }
509
510 if let Some(caps) = DROP_VIEW_RE.captures(stmt) {
512 let name = caps.get(3).unwrap().as_str().to_string();
513 return vec![(
514 DdlOperation::DropView { name },
515 caps.get(0).unwrap().start(),
516 )];
517 }
518
519 if let Some(caps) = CREATE_FUNCTION_RE.captures(stmt) {
521 let name = caps.get(2).unwrap().as_str().to_string();
522 return vec![(
523 DdlOperation::CreateFunction { name },
524 caps.get(0).unwrap().start(),
525 )];
526 }
527
528 if let Some(caps) = DROP_FUNCTION_RE.captures(stmt) {
530 let name = caps.get(2).unwrap().as_str().to_string();
531 return vec![(
532 DdlOperation::DropFunction { name },
533 caps.get(0).unwrap().start(),
534 )];
535 }
536
537 if let Some(caps) = CREATE_ENUM_RE.captures(stmt) {
539 let name = caps.get(2).unwrap().as_str().to_string();
540 return vec![(
541 DdlOperation::CreateEnum { name },
542 caps.get(0).unwrap().start(),
543 )];
544 }
545
546 if let Some(caps) = TRUNCATE_RE.captures(stmt) {
548 let table = caps.get(2).unwrap().as_str().to_string();
549 return vec![(
550 DdlOperation::TruncateTable { table },
551 caps.get(0).unwrap().start(),
552 )];
553 }
554
555 Vec::new()
556}
557
558#[derive(Debug, Clone)]
564struct AddColumnClause {
565 column: String,
566 column_offset: usize,
568 data_type: String,
569 if_not_exists: bool,
570 is_not_null: bool,
571 has_default: bool,
572 default_expr: Option<String>,
573}
574
575const COLUMN_CONSTRAINT_KEYWORDS: &[&str] = &[
579 "NOT",
580 "NULL",
581 "DEFAULT",
582 "CHECK",
583 "UNIQUE",
584 "PRIMARY",
585 "REFERENCES",
586 "CONSTRAINT",
587 "GENERATED",
588 "COLLATE",
589 "DEFERRABLE",
590 "INITIALLY",
591 "COMMENT",
592 "AUTO_INCREMENT",
593 "IDENTITY",
594 "STORAGE",
595 "COMPRESSION",
596 "VISIBLE",
597 "INVISIBLE",
598 "FIRST",
599 "AFTER",
600];
601
602const TABLE_CONSTRAINT_KEYWORDS: &[&str] = &[
605 "CONSTRAINT",
606 "PRIMARY",
607 "UNIQUE",
608 "FOREIGN",
609 "CHECK",
610 "EXCLUDE",
611 "INDEX",
612 "KEY",
613 "FULLTEXT",
614 "SPATIAL",
615];
616
617fn parse_add_columns(stmt: &str) -> Option<(String, Vec<AddColumnClause>)> {
627 let toks = tokenize(stmt);
628
629 let mut i = toks
631 .windows(2)
632 .position(|w| is_kw(&w[0], "ALTER") && is_kw(&w[1], "TABLE"))?
633 + 2;
634
635 if kw_at(&toks, i, "IF") && kw_at(&toks, i + 1, "EXISTS") {
637 i += 2;
638 }
639 if kw_at(&toks, i, "ONLY") {
640 i += 1;
641 }
642
643 let mut name_parts: Vec<&str> = Vec::new();
645 loop {
646 let t = toks.get(i)?;
647 if !is_identifier(t) {
648 return None;
649 }
650 name_parts.push(t.text);
651 i += 1;
652 match toks.get(i) {
653 Some(t) if t.kind == TokKind::Punct && t.text == "." => i += 1,
654 _ => break,
655 }
656 }
657 if toks
659 .get(i)
660 .is_some_and(|t| t.kind == TokKind::Punct && t.text == "*")
661 {
662 i += 1;
663 }
664 let table = (*name_parts.last()?).to_string();
665
666 let mut clauses = Vec::new();
668 let mut depth = 0usize;
669 while i < toks.len() {
670 let t = &toks[i];
671 if t.kind == TokKind::Punct {
672 match t.text {
673 "(" => depth += 1,
674 ")" => depth = depth.saturating_sub(1),
675 _ => {}
676 }
677 i += 1;
678 continue;
679 }
680 if depth == 0
681 && is_kw(t, "ADD")
682 && let Some((clause, next)) = parse_one_add_column(stmt, &toks, i + 1)
683 {
684 clauses.push(clause);
685 i = next;
686 continue;
687 }
688 i += 1;
689 }
690
691 if clauses.is_empty() {
692 None
693 } else {
694 Some((table, clauses))
695 }
696}
697
698fn parse_one_add_column<'a>(
703 stmt: &'a str,
704 toks: &[Tok<'a>],
705 mut i: usize,
706) -> Option<(AddColumnClause, usize)> {
707 if kw_at(toks, i, "COLUMN") {
708 i += 1;
709 }
710
711 let mut if_not_exists = false;
712 if kw_at(toks, i, "IF") && kw_at(toks, i + 1, "NOT") && kw_at(toks, i + 2, "EXISTS") {
713 if_not_exists = true;
714 i += 3;
715 }
716
717 let col = toks.get(i)?;
718 if !is_identifier(col) {
719 return None;
720 }
721 if col.kind == TokKind::Word
723 && TABLE_CONSTRAINT_KEYWORDS
724 .iter()
725 .any(|k| col.text.eq_ignore_ascii_case(k))
726 {
727 return None;
728 }
729 let column = col.text.to_string();
730 let column_offset = col.start;
731 i += 1;
732
733 let type_first = i;
736 let mut depth = 0usize;
737 while i < toks.len() {
738 let t = &toks[i];
739 if t.kind == TokKind::Punct {
740 match t.text {
741 "(" => depth += 1,
742 ")" => {
743 if depth == 0 {
744 break;
745 }
746 depth -= 1;
747 }
748 "," if depth == 0 => break,
749 _ => {}
750 }
751 i += 1;
752 continue;
753 }
754 if depth == 0
755 && t.kind == TokKind::Word
756 && COLUMN_CONSTRAINT_KEYWORDS
757 .iter()
758 .any(|k| t.text.eq_ignore_ascii_case(k))
759 {
760 break;
761 }
762 i += 1;
763 }
764 let data_type = if i > type_first {
765 normalize_whitespace(&stmt[toks[type_first].start..toks[i - 1].end])
766 } else {
767 "unknown".to_string()
768 };
769
770 let mut is_not_null = false;
772 let mut default_expr = None;
773 let mut depth = 0usize;
774 while i < toks.len() {
775 let t = &toks[i];
776 if t.kind == TokKind::Punct {
777 match t.text {
778 "(" => depth += 1,
779 ")" => {
780 if depth == 0 {
781 break;
782 }
783 depth -= 1;
784 }
785 "," if depth == 0 => {
786 i += 1;
787 break;
788 }
789 _ => {}
790 }
791 i += 1;
792 continue;
793 }
794 if depth == 0 && t.kind == TokKind::Word {
795 if is_kw(t, "NOT") && kw_at(toks, i + 1, "NULL") {
796 is_not_null = true;
797 i += 2;
798 continue;
799 }
800 if is_kw(t, "DEFAULT") {
801 let (expr, next) = read_default_expr(stmt, toks, i + 1);
802 default_expr = Some(expr);
803 i = next;
804 continue;
805 }
806 }
807 i += 1;
808 }
809
810 Some((
811 AddColumnClause {
812 column,
813 column_offset,
814 data_type,
815 if_not_exists,
816 is_not_null,
817 has_default: default_expr.is_some(),
818 default_expr,
819 },
820 i,
821 ))
822}
823
824fn read_default_expr(stmt: &str, toks: &[Tok<'_>], start: usize) -> (String, usize) {
830 let mut i = start;
831 let mut depth = 0usize;
832 while i < toks.len() {
833 let t = &toks[i];
834 if t.kind == TokKind::Punct {
835 match t.text {
836 "(" => depth += 1,
837 ")" => {
838 if depth == 0 {
839 break;
840 }
841 depth -= 1;
842 }
843 "," if depth == 0 => break,
844 _ => {}
845 }
846 i += 1;
847 continue;
848 }
849 if depth == 0
850 && i > start
851 && t.kind == TokKind::Word
852 && COLUMN_CONSTRAINT_KEYWORDS
853 .iter()
854 .any(|k| t.text.eq_ignore_ascii_case(k))
855 {
856 break;
857 }
858 i += 1;
859 }
860
861 let expr = if i > start {
862 normalize_whitespace(&stmt[toks[start].start..toks[i - 1].end])
863 } else {
864 String::new()
865 };
866 (expr, i)
867}
868
869fn normalize_whitespace(s: &str) -> String {
871 s.split_whitespace().collect::<Vec<_>>().join(" ")
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879enum TokKind {
880 Word,
882 Ident,
884 Literal,
886 Punct,
888}
889
890#[derive(Debug, Clone, Copy)]
891struct Tok<'a> {
892 kind: TokKind,
893 text: &'a str,
894 start: usize,
895 end: usize,
896}
897
898fn is_kw(tok: &Tok<'_>, kw: &str) -> bool {
899 tok.kind == TokKind::Word && tok.text.eq_ignore_ascii_case(kw)
900}
901
902fn kw_at(toks: &[Tok<'_>], i: usize, kw: &str) -> bool {
903 toks.get(i).is_some_and(|t| is_kw(t, kw))
904}
905
906fn is_identifier(tok: &Tok<'_>) -> bool {
908 match tok.kind {
909 TokKind::Ident => true,
910 TokKind::Word => tok
911 .text
912 .starts_with(|c: char| c.is_alphabetic() || c == '_'),
913 _ => false,
914 }
915}
916
917fn tokenize(sql: &str) -> Vec<Tok<'_>> {
919 let bytes = sql.as_bytes();
920 let len = bytes.len();
921 let mut toks = Vec::new();
922 let mut i = 0;
923
924 while i < len {
925 let c = bytes[i];
926 if c.is_ascii_whitespace() {
927 i += 1;
928 continue;
929 }
930 if let Some(j) = skip_comment(bytes, i) {
931 i = j;
932 continue;
933 }
934 if c == b'\'' {
935 let j = skip_quoted(sql, i).unwrap_or(len);
936 toks.push(Tok {
937 kind: TokKind::Literal,
938 text: &sql[i..j],
939 start: i,
940 end: j,
941 });
942 i = j;
943 continue;
944 }
945 if c == b'"' || c == b'`' {
946 let mut j = i + 1;
947 while j < len {
948 if bytes[j] == c {
949 if j + 1 < len && bytes[j + 1] == c {
950 j += 2;
951 continue;
952 }
953 break;
954 }
955 j += 1;
956 }
957 let inner_end = j.min(len);
958 let end = (j + 1).min(len);
959 toks.push(Tok {
960 kind: TokKind::Ident,
961 text: &sql[i + 1..inner_end],
962 start: i,
963 end,
964 });
965 i = end;
966 continue;
967 }
968 if c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 {
969 let mut j = i;
970 while j < len
971 && (bytes[j].is_ascii_alphanumeric()
972 || bytes[j] == b'_'
973 || bytes[j] == b'$'
974 || bytes[j] >= 0x80)
975 {
976 j += 1;
977 }
978 toks.push(Tok {
979 kind: TokKind::Word,
980 text: &sql[i..j],
981 start: i,
982 end: j,
983 });
984 i = j;
985 continue;
986 }
987 toks.push(Tok {
988 kind: TokKind::Punct,
989 text: &sql[i..i + 1],
990 start: i,
991 end: i + 1,
992 });
993 i += 1;
994 }
995
996 toks
997}
998
999pub fn split_statements(sql: &str) -> Vec<&str> {
1002 statement_ranges(sql)
1003 .into_iter()
1004 .map(|(s, e)| &sql[s..e])
1005 .collect()
1006}
1007
1008fn statement_ranges(sql: &str) -> Vec<(usize, usize)> {
1011 let bytes = sql.as_bytes();
1012 let len = bytes.len();
1013 let mut ranges = Vec::new();
1014 let mut start = 0;
1015 let mut i = 0;
1016
1017 while i < len {
1018 if let Some(j) = skip_comment(bytes, i) {
1019 i = j;
1020 continue;
1021 }
1022 if let Some(j) = skip_quoted(sql, i) {
1023 i = j;
1024 continue;
1025 }
1026 if bytes[i] == b';' {
1027 if let Some(r) = trim_range(sql, start, i) {
1028 ranges.push(r);
1029 }
1030 i += 1;
1031 start = i;
1032 continue;
1033 }
1034 i += 1;
1035 }
1036
1037 if let Some(r) = trim_range(sql, start, len) {
1039 ranges.push(r);
1040 }
1041
1042 ranges
1043}
1044
1045fn trim_range(sql: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1048 let slice = &sql[start..end];
1049 if slice.trim().is_empty() {
1050 return None;
1051 }
1052 let lead = slice.len() - slice.trim_start().len();
1053 let trail = slice.len() - slice.trim_end().len();
1054 Some((start + lead, end - trail))
1055}
1056
1057fn skip_comment(bytes: &[u8], i: usize) -> Option<usize> {
1062 let len = bytes.len();
1063 if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1064 let mut j = i + 2;
1065 while j < len && bytes[j] != b'\n' {
1066 j += 1;
1067 }
1068 return Some(j);
1069 }
1070 if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1071 let mut j = i + 2;
1072 let mut depth = 1usize;
1073 while j < len && depth > 0 {
1074 if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
1075 depth += 1;
1076 j += 2;
1077 } else if j + 1 < len && bytes[j] == b'*' && bytes[j + 1] == b'/' {
1078 depth -= 1;
1079 j += 2;
1080 } else {
1081 j += 1;
1082 }
1083 }
1084 return Some(j.min(len));
1085 }
1086 None
1087}
1088
1089fn skip_quoted(sql: &str, i: usize) -> Option<usize> {
1095 let bytes = sql.as_bytes();
1096 let len = bytes.len();
1097 match bytes[i] {
1098 b'\'' => {
1099 let is_escape_string = i > 0
1101 && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
1102 && (i < 2 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_'));
1103 let mut j = i + 1;
1104 while j < len {
1105 if is_escape_string && bytes[j] == b'\\' {
1106 j += 2;
1107 continue;
1108 }
1109 if bytes[j] == b'\'' {
1110 if j + 1 < len && bytes[j + 1] == b'\'' {
1111 j += 2; } else {
1113 j += 1;
1114 break;
1115 }
1116 } else {
1117 j += 1;
1118 }
1119 }
1120 Some(j.min(len))
1121 }
1122 q @ (b'"' | b'`') => {
1123 let mut j = i + 1;
1124 while j < len {
1125 if bytes[j] == q {
1126 if j + 1 < len && bytes[j + 1] == q {
1127 j += 2; continue;
1129 }
1130 j += 1;
1131 break;
1132 }
1133 j += 1;
1134 }
1135 Some(j.min(len))
1136 }
1137 b'$' => {
1139 let tag_start = i;
1140 let mut j = i + 1;
1141 while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
1142 j += 1;
1143 }
1144 if j < len && bytes[j] == b'$' {
1145 let tag = &sql[tag_start..=j];
1146 j += 1;
1147 while j < len {
1148 if bytes[j] == b'$' && sql[j..].starts_with(tag) {
1149 j += tag.len();
1150 break;
1151 }
1152 j += 1;
1153 }
1154 }
1155 Some(j.min(len))
1156 }
1157 _ => None,
1158 }
1159}
1160
1161pub fn blank_string_literals(sql: &str) -> String {
1173 let bytes = sql.as_bytes();
1174 let len = bytes.len();
1175 let mut out = bytes.to_vec();
1176 let mut i = 0;
1177
1178 while i < len {
1179 if let Some(j) = skip_comment(bytes, i) {
1182 i = j;
1183 continue;
1184 }
1185 if let Some(j) = skip_quoted(sql, i) {
1186 if bytes[i] == b'\'' {
1187 let inner_end = j.saturating_sub(1).max(i + 1);
1189 for b in &mut out[i + 1..inner_end.min(len)] {
1190 if *b != b'\n' {
1191 *b = b' ';
1192 }
1193 }
1194 }
1195 i = j;
1196 continue;
1197 }
1198 i += 1;
1199 }
1200
1201 String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1204}
1205
1206pub fn strip_comments(sql: &str) -> String {
1213 let bytes = sql.as_bytes();
1214 let len = bytes.len();
1215 let mut out = bytes.to_vec();
1216 let mut i = 0;
1217
1218 while i < len {
1219 if let Some(j) = skip_comment(bytes, i) {
1220 for b in &mut out[i..j] {
1221 if *b != b'\n' {
1222 *b = b' ';
1223 }
1224 }
1225 i = j;
1226 continue;
1227 }
1228 if let Some(j) = skip_quoted(sql, i) {
1229 i = j;
1230 continue;
1231 }
1232 i += 1;
1233 }
1234
1235 String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1238}
1239
1240pub fn line_number_at(sql: &str, offset: usize) -> usize {
1242 sql[..offset.min(sql.len())]
1243 .bytes()
1244 .filter(|b| *b == b'\n')
1245 .count()
1246 + 1
1247}
1248
1249pub fn split_mysql_statements(sql: &str) -> Vec<String> {
1260 let bytes = sql.as_bytes();
1261 let len = bytes.len();
1262 let mut out = Vec::new();
1263 let mut start = 0;
1264 let mut i = 0;
1265 while i < len {
1266 let c = bytes[i];
1267 if c == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1269 while i < len && bytes[i] != b'\n' {
1270 i += 1;
1271 }
1272 continue;
1273 }
1274 if c == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1276 i += 2;
1277 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1278 i += 1;
1279 }
1280 i = (i + 2).min(len);
1281 continue;
1282 }
1283 if c == b'\'' {
1285 i += 1;
1286 while i < len && bytes[i] != b'\'' {
1287 if bytes[i] == b'\\' && i + 1 < len {
1288 i += 2;
1289 } else {
1290 i += 1;
1291 }
1292 }
1293 i += 1;
1294 continue;
1295 }
1296 if c == b'"' {
1298 i += 1;
1299 while i < len && bytes[i] != b'"' {
1300 if bytes[i] == b'\\' && i + 1 < len {
1301 i += 2;
1302 } else {
1303 i += 1;
1304 }
1305 }
1306 i += 1;
1307 continue;
1308 }
1309 if c == b'`' {
1311 i += 1;
1312 while i < len && bytes[i] != b'`' {
1313 i += 1;
1314 }
1315 i += 1;
1316 continue;
1317 }
1318 if c == b';' {
1320 push_mysql_statement(&mut out, &sql[start..i]);
1321 i += 1;
1322 start = i;
1323 continue;
1324 }
1325 i += 1;
1326 }
1327 push_mysql_statement(&mut out, &sql[start..]);
1328 out
1329}
1330
1331fn push_mysql_statement(out: &mut Vec<String>, candidate: &str) {
1339 let trimmed = candidate.trim();
1340 if trimmed.is_empty() {
1341 return;
1342 }
1343 if strip_comments(trimmed).trim().is_empty() {
1344 return;
1345 }
1346 out.push(trimmed.to_string());
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352
1353 #[test]
1354 fn test_split_simple_statements() {
1355 let sql = "SELECT 1; SELECT 2;";
1356 let stmts = split_statements(sql);
1357 assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
1358 }
1359
1360 #[test]
1361 fn test_split_respects_string_literals() {
1362 let sql = "SELECT 'hello;world'; SELECT 2;";
1363 let stmts = split_statements(sql);
1364 assert_eq!(stmts, vec!["SELECT 'hello;world'", "SELECT 2"]);
1365 }
1366
1367 #[test]
1368 fn test_split_respects_dollar_quoting() {
1369 let sql =
1370 "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql; SELECT 1;";
1371 let stmts = split_statements(sql);
1372 assert_eq!(stmts.len(), 2);
1373 assert!(stmts[0].contains("BEGIN; END;"));
1374 }
1375
1376 #[test]
1377 fn test_split_respects_tagged_dollar_quoting() {
1378 let sql = "CREATE FUNCTION foo() RETURNS void AS $body$ BEGIN; END; $body$ LANGUAGE plpgsql; SELECT 1;";
1379 let stmts = split_statements(sql);
1380 assert_eq!(stmts.len(), 2);
1381 assert!(stmts[0].contains("BEGIN; END;"));
1382 }
1383
1384 #[test]
1385 fn test_split_respects_comments() {
1386 let sql = "-- This is a comment with ; semicolon\nSELECT 1;";
1387 let stmts = split_statements(sql);
1388 assert_eq!(stmts.len(), 1);
1389 }
1390
1391 #[test]
1392 fn test_split_no_trailing_semicolon() {
1393 let sql = "SELECT 1";
1394 let stmts = split_statements(sql);
1395 assert_eq!(stmts, vec!["SELECT 1"]);
1396 }
1397
1398 #[test]
1399 fn test_extract_create_table() {
1400 let sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);";
1401 let ops = extract_ddl_operations(sql);
1402 assert_eq!(ops.len(), 1);
1403 match &ops[0] {
1404 DdlOperation::CreateTable {
1405 table,
1406 if_not_exists,
1407 } => {
1408 assert_eq!(table, "users");
1409 assert!(!if_not_exists);
1410 }
1411 _ => panic!("Expected CreateTable"),
1412 }
1413 }
1414
1415 #[test]
1416 fn test_extract_create_table_if_not_exists() {
1417 let sql = "CREATE TABLE IF NOT EXISTS users (id SERIAL);";
1418 let ops = extract_ddl_operations(sql);
1419 match &ops[0] {
1420 DdlOperation::CreateTable {
1421 table,
1422 if_not_exists,
1423 } => {
1424 assert_eq!(table, "users");
1425 assert!(if_not_exists);
1426 }
1427 _ => panic!("Expected CreateTable"),
1428 }
1429 }
1430
1431 #[test]
1432 fn test_extract_add_column() {
1433 let sql = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';";
1434 let ops = extract_ddl_operations(sql);
1435 match &ops[0] {
1436 DdlOperation::AlterTableAddColumn {
1437 table,
1438 column,
1439 is_not_null,
1440 has_default,
1441 ..
1442 } => {
1443 assert_eq!(table, "users");
1444 assert_eq!(column, "email");
1445 assert!(is_not_null);
1446 assert!(has_default);
1447 }
1448 _ => panic!("Expected AlterTableAddColumn"),
1449 }
1450 }
1451
1452 fn add_column(sql: &str) -> DdlOperation {
1454 let ops = extract_ddl_operations(sql);
1455 assert_eq!(ops.len(), 1, "expected exactly one op, got {:?}", ops);
1456 ops.into_iter().next().unwrap()
1457 }
1458
1459 #[test]
1460 fn test_add_column_if_not_exists_names_the_column() {
1461 match add_column(
1462 "ALTER TABLE dicom.reid_shares ADD COLUMN IF NOT EXISTS threshold smallint;",
1463 ) {
1464 DdlOperation::AlterTableAddColumn {
1465 table,
1466 column,
1467 data_type,
1468 is_not_null,
1469 has_default,
1470 if_not_exists,
1471 default_expr,
1472 } => {
1473 assert_eq!(table, "reid_shares");
1474 assert_eq!(column, "threshold");
1475 assert_eq!(data_type, "smallint");
1476 assert!(default_expr.is_none());
1477 assert!(!is_not_null);
1478 assert!(!has_default);
1479 assert!(if_not_exists);
1480 }
1481 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1482 }
1483 }
1484
1485 #[test]
1486 fn test_add_column_ignores_not_null_in_comments() {
1487 let sql = "-- Every ceremony writes the threshold NOT NULL.\n\
1488 ALTER TABLE dicom.reid_shares\n \
1489 ADD COLUMN IF NOT EXISTS threshold smallint\n \
1490 CHECK (threshold IS NULL OR threshold BETWEEN 1 AND 255);";
1491 match add_column(sql) {
1492 DdlOperation::AlterTableAddColumn {
1493 column,
1494 is_not_null,
1495 ..
1496 } => {
1497 assert_eq!(column, "threshold");
1498 assert!(!is_not_null, "NOT NULL came from a comment");
1499 }
1500 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1501 }
1502 }
1503
1504 #[test]
1505 fn test_add_column_ignores_not_null_inside_check() {
1506 match add_column("ALTER TABLE t ADD COLUMN c text CHECK (c IS NOT NULL);") {
1507 DdlOperation::AlterTableAddColumn { is_not_null, .. } => assert!(!is_not_null),
1508 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1509 }
1510 }
1511
1512 #[test]
1513 fn test_add_column_ignores_keywords_inside_string_literals() {
1514 match add_column("ALTER TABLE t ADD COLUMN c text DEFAULT 'NOT NULL';") {
1515 DdlOperation::AlterTableAddColumn {
1516 is_not_null,
1517 has_default,
1518 ..
1519 } => {
1520 assert!(!is_not_null);
1521 assert!(has_default);
1522 }
1523 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1524 }
1525 }
1526
1527 #[test]
1528 fn test_add_column_quoted_and_parenthesised_type() {
1529 match add_column(
1530 r#"ALTER TABLE "my schema"."my table" ADD "my col" numeric(10,2) NOT NULL;"#,
1531 ) {
1532 DdlOperation::AlterTableAddColumn {
1533 table,
1534 column,
1535 data_type,
1536 is_not_null,
1537 ..
1538 } => {
1539 assert_eq!(table, "my table");
1540 assert_eq!(column, "my col");
1541 assert_eq!(data_type, "numeric(10,2)");
1542 assert!(is_not_null);
1543 }
1544 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1545 }
1546 }
1547
1548 #[test]
1549 fn test_add_column_multiword_type() {
1550 match add_column("ALTER TABLE t ADD COLUMN c timestamp with time zone NOT NULL;") {
1551 DdlOperation::AlterTableAddColumn {
1552 data_type,
1553 is_not_null,
1554 ..
1555 } => {
1556 assert_eq!(data_type, "timestamp with time zone");
1557 assert!(is_not_null);
1558 }
1559 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1560 }
1561 }
1562
1563 #[test]
1564 fn test_add_multiple_columns_in_one_statement() {
1565 let ops = extract_ddl_operations(
1566 "ALTER TABLE t ADD COLUMN a int, ADD COLUMN IF NOT EXISTS b text NOT NULL;",
1567 );
1568 assert_eq!(ops.len(), 2);
1569 match (&ops[0], &ops[1]) {
1570 (
1571 DdlOperation::AlterTableAddColumn {
1572 column: c1,
1573 is_not_null: n1,
1574 ..
1575 },
1576 DdlOperation::AlterTableAddColumn {
1577 column: c2,
1578 is_not_null: n2,
1579 if_not_exists,
1580 ..
1581 },
1582 ) => {
1583 assert_eq!(c1, "a");
1584 assert!(!n1);
1585 assert_eq!(c2, "b");
1586 assert!(n2);
1587 assert!(if_not_exists);
1588 }
1589 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1590 }
1591 }
1592
1593 #[test]
1594 fn test_add_column_captures_default_expression() {
1595 for (sql, expected) in [
1596 (
1597 "ALTER TABLE t ADD COLUMN c timestamptz DEFAULT now();",
1598 "now()",
1599 ),
1600 ("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 5;", "5"),
1601 (
1602 "ALTER TABLE t ADD COLUMN c text DEFAULT 'x' NOT NULL;",
1603 "'x'",
1604 ),
1605 ("ALTER TABLE t ADD COLUMN c text DEFAULT NULL;", "NULL"),
1606 (
1607 "ALTER TABLE t ADD COLUMN c text[] DEFAULT '{}' CHECK (c IS NOT NULL);",
1608 "'{}'",
1609 ),
1610 ] {
1611 match add_column(sql) {
1612 DdlOperation::AlterTableAddColumn { default_expr, .. } => {
1613 assert_eq!(default_expr.as_deref(), Some(expected), "for {}", sql)
1614 }
1615 other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1616 }
1617 }
1618 }
1619
1620 #[test]
1621 fn test_add_column_default_is_per_clause() {
1622 let ops = extract_ddl_operations(
1623 "ALTER TABLE t ADD COLUMN a text[] DEFAULT '{}', ADD COLUMN b timestamptz NOT NULL DEFAULT now();",
1624 );
1625 assert_eq!(ops.len(), 2);
1626 match (&ops[0], &ops[1]) {
1627 (
1628 DdlOperation::AlterTableAddColumn {
1629 default_expr: a, ..
1630 },
1631 DdlOperation::AlterTableAddColumn {
1632 default_expr: b, ..
1633 },
1634 ) => {
1635 assert_eq!(a.as_deref(), Some("'{}'"));
1636 assert_eq!(b.as_deref(), Some("now()"));
1637 }
1638 other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1639 }
1640 }
1641
1642 #[test]
1643 fn test_add_constraint_is_not_parsed_as_a_column() {
1644 let ops = extract_ddl_operations("ALTER TABLE t ADD CONSTRAINT t_pk PRIMARY KEY (id);");
1645 assert!(matches!(ops[0], DdlOperation::AddConstraint { .. }));
1646 }
1647
1648 #[test]
1649 fn test_located_ops_point_at_the_column() {
1650 let sql =
1651 "-- comment with NOT NULL\n-- another\nALTER TABLE t\n ADD COLUMN c int NOT NULL;";
1652 let located = extract_ddl_operations_located(sql);
1653 assert_eq!(located.len(), 1);
1654 assert_eq!(line_number_at(sql, located[0].start), 3);
1656 assert_eq!(line_number_at(sql, located[0].focus), 4);
1657 assert_eq!(&sql[located[0].focus..located[0].focus + 1], "c");
1658 }
1659
1660 #[test]
1661 fn test_strip_comments_preserves_offsets_and_lines() {
1662 let sql = "-- NOT NULL\nSELECT 1; /* NOT NULL */\nSELECT 'not -- a comment';";
1663 let stripped = strip_comments(sql);
1664 assert_eq!(stripped.len(), sql.len());
1665 assert_eq!(stripped.lines().count(), sql.lines().count());
1666 assert!(!stripped.to_uppercase().contains("NOT NULL"));
1667 assert!(stripped.contains("'not -- a comment'"));
1668 }
1669
1670 #[test]
1671 fn test_line_number_at_is_one_based() {
1672 let sql = "a\nb\nc";
1673 assert_eq!(line_number_at(sql, 0), 1);
1674 assert_eq!(line_number_at(sql, 2), 2);
1675 assert_eq!(line_number_at(sql, 4), 3);
1676 }
1677
1678 #[test]
1679 fn test_extract_create_index() {
1680 let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email);";
1681 let ops = extract_ddl_operations(sql);
1682 match &ops[0] {
1683 DdlOperation::CreateIndex {
1684 name,
1685 table,
1686 is_concurrent,
1687 is_unique,
1688 } => {
1689 assert_eq!(name, "idx_users_email");
1690 assert_eq!(table, "users");
1691 assert!(is_concurrent);
1692 assert!(is_unique);
1693 }
1694 _ => panic!("Expected CreateIndex"),
1695 }
1696 }
1697
1698 #[test]
1699 fn test_extract_create_function() {
1700 let sql = "CREATE OR REPLACE FUNCTION my_func() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;";
1701 let ops = extract_ddl_operations(sql);
1702 match &ops[0] {
1703 DdlOperation::CreateFunction { name } => {
1704 assert_eq!(name, "my_func");
1705 }
1706 _ => panic!("Expected CreateFunction, got {:?}", ops[0]),
1707 }
1708 }
1709
1710 #[test]
1711 fn test_extract_create_enum() {
1712 let sql = "CREATE TYPE mood AS ENUM ('happy', 'sad');";
1713 let ops = extract_ddl_operations(sql);
1714 match &ops[0] {
1715 DdlOperation::CreateEnum { name } => {
1716 assert_eq!(name, "mood");
1717 }
1718 _ => panic!("Expected CreateEnum"),
1719 }
1720 }
1721
1722 #[test]
1723 fn test_extract_multiple() {
1724 let sql = "CREATE TABLE users (id SERIAL); CREATE INDEX idx_users ON users (id); DROP TABLE old_table;";
1725 let ops = extract_ddl_operations(sql);
1726 assert_eq!(ops.len(), 3);
1727 }
1728
1729 #[test]
1730 fn test_extract_truncate() {
1731 let sql = "TRUNCATE TABLE users;";
1732 let ops = extract_ddl_operations(sql);
1733 match &ops[0] {
1734 DdlOperation::TruncateTable { table } => assert_eq!(table, "users"),
1735 _ => panic!("Expected TruncateTable"),
1736 }
1737 }
1738
1739 #[test]
1740 fn test_extract_drop_column() {
1741 let sql = "ALTER TABLE users DROP COLUMN email;";
1742 let ops = extract_ddl_operations(sql);
1743 match &ops[0] {
1744 DdlOperation::AlterTableDropColumn { table, column } => {
1745 assert_eq!(table, "users");
1746 assert_eq!(column, "email");
1747 }
1748 _ => panic!("Expected AlterTableDropColumn"),
1749 }
1750 }
1751
1752 #[test]
1753 fn test_extract_alter_column() {
1754 let sql = "ALTER TABLE users ALTER COLUMN name TYPE text;";
1755 let ops = extract_ddl_operations(sql);
1756 match &ops[0] {
1757 DdlOperation::AlterTableAlterColumn { table, column } => {
1758 assert_eq!(table, "users");
1759 assert_eq!(column, "name");
1760 }
1761 _ => panic!("Expected AlterTableAlterColumn"),
1762 }
1763 }
1764
1765 #[test]
1766 fn test_extract_materialized_view() {
1767 let sql = "CREATE MATERIALIZED VIEW user_stats AS SELECT count(*) FROM users;";
1768 let ops = extract_ddl_operations(sql);
1769 match &ops[0] {
1770 DdlOperation::CreateView {
1771 name,
1772 is_materialized,
1773 } => {
1774 assert_eq!(name, "user_stats");
1775 assert!(is_materialized);
1776 }
1777 _ => panic!("Expected CreateView"),
1778 }
1779 }
1780
1781 #[test]
1782 fn test_block_comment_with_semicolons() {
1783 let sql = "/* comment; with; semicolons */ SELECT 1;";
1784 let stmts = split_statements(sql);
1785 assert_eq!(stmts.len(), 1);
1786 }
1787
1788 #[test]
1789 fn test_escaped_string_quotes() {
1790 let sql = "SELECT 'it''s; here'; SELECT 2;";
1791 let stmts = split_statements(sql);
1792 assert_eq!(stmts.len(), 2);
1793 }
1794
1795 #[test]
1796 fn test_split_respects_e_escape_strings() {
1797 let sql = r"SELECT E'hello\';world'; SELECT 2;";
1798 let stmts = split_statements(sql);
1799 assert_eq!(stmts.len(), 2);
1800 assert!(stmts[0].contains(r"E'hello\';world'"));
1801 }
1802
1803 #[test]
1804 fn test_split_e_string_with_backslash() {
1805 let sql = r"SELECT E'it\'s a test; really'; SELECT 1;";
1806 let stmts = split_statements(sql);
1807 assert_eq!(stmts.len(), 2);
1808 }
1809
1810 #[test]
1811 fn test_split_nested_block_comments() {
1812 let sql = "SELECT /* outer /* inner */ outer */ 1; SELECT 2;";
1813 let stmts = split_statements(sql);
1814 assert_eq!(stmts.len(), 2);
1815 assert_eq!(stmts[1], "SELECT 2");
1816 }
1817
1818 #[test]
1819 fn test_split_whitespace_only() {
1820 let stmts = split_statements(" \n\t ");
1821 assert!(stmts.is_empty());
1822 }
1823
1824 #[test]
1825 fn test_split_comment_only() {
1826 let stmts = split_statements("-- just a comment\n");
1827 assert_eq!(stmts.len(), 1);
1828 assert_eq!(stmts[0], "-- just a comment");
1829 }
1830
1831 #[test]
1832 fn test_split_mixed_e_and_regular_strings() {
1833 let sql = r"SELECT 'normal;string', E'escape\';string'; SELECT 2;";
1834 let stmts = split_statements(sql);
1835 assert_eq!(stmts.len(), 2);
1836 }
1837
1838 #[test]
1839 fn test_split_mysql_basic() {
1840 let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1841 let stmts = split_mysql_statements(sql);
1842 assert_eq!(stmts.len(), 2);
1843 assert!(stmts[0].contains("CREATE TABLE a"));
1844 assert!(stmts[1].contains("CREATE TABLE b"));
1845 }
1846
1847 #[test]
1848 fn test_split_mysql_respects_backticks_with_semicolons() {
1849 let sql = "CREATE TABLE `weird;name` (id INT); CREATE TABLE b (id INT);";
1851 let stmts = split_mysql_statements(sql);
1852 assert_eq!(stmts.len(), 2);
1853 assert!(stmts[0].contains("`weird;name`"));
1854 }
1855
1856 #[test]
1857 fn test_split_mysql_respects_string_literals_with_semicolons() {
1858 let sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c;d');";
1859 let stmts = split_mysql_statements(sql);
1860 assert_eq!(stmts.len(), 2);
1861 }
1862
1863 #[test]
1864 fn test_split_mysql_keeps_leading_comments_with_statement() {
1865 let sql = "-- header comment\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
1868 let stmts = split_mysql_statements(sql);
1869 assert_eq!(stmts.len(), 2);
1870 assert!(stmts[0].contains("CREATE TABLE a"));
1871 }
1872
1873 #[test]
1874 fn test_split_mysql_handles_block_comments() {
1875 let sql = "/* block ; comment */ CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1876 let stmts = split_mysql_statements(sql);
1877 assert_eq!(stmts.len(), 2);
1878 }
1879
1880 #[test]
1881 fn test_split_mysql_no_trailing_semicolon() {
1882 let sql = "CREATE TABLE a (id INT)";
1883 let stmts = split_mysql_statements(sql);
1884 assert_eq!(stmts.len(), 1);
1885 assert!(stmts[0].contains("CREATE TABLE a"));
1886 }
1887
1888 #[test]
1889 fn test_split_mysql_drops_trailing_comment_only_statement() {
1890 let sql = "CREATE TABLE t (id INT);\n-- done\n";
1893 assert_eq!(
1894 split_mysql_statements(sql),
1895 vec!["CREATE TABLE t (id INT)".to_string()]
1896 );
1897 }
1898
1899 #[test]
1900 fn test_split_mysql_drops_empty_statements() {
1901 let sql = "SELECT 1;; SELECT 2;";
1902 assert_eq!(
1903 split_mysql_statements(sql),
1904 vec!["SELECT 1".to_string(), "SELECT 2".to_string()]
1905 );
1906 }
1907
1908 #[test]
1909 fn test_split_mysql_trims_every_statement() {
1910 let sql = "SELECT 1;\n SELECT 2 ;\n";
1911 assert_eq!(
1912 split_mysql_statements(sql),
1913 vec!["SELECT 1".to_string(), "SELECT 2".to_string()]
1914 );
1915 }
1916
1917 #[test]
1918 fn test_split_mysql_drops_block_comment_only_statement() {
1919 let sql = "SELECT 1; /* just a note */ ;";
1920 assert_eq!(split_mysql_statements(sql), vec!["SELECT 1".to_string()]);
1921 }
1922
1923 #[test]
1924 fn test_split_mysql_keeps_statement_with_leading_comment() {
1925 let sql = "-- set up\nCREATE TABLE t (id INT);";
1926 assert_eq!(
1927 split_mysql_statements(sql),
1928 vec!["-- set up\nCREATE TABLE t (id INT)".to_string()]
1929 );
1930 }
1931
1932 #[test]
1933 fn test_blank_string_literals_preserves_offsets_and_quotes() {
1934 let sql = "INSERT INTO t VALUES ('nightly VACUUM run');";
1935 let out = blank_string_literals(sql);
1936 assert_eq!(out.len(), sql.len(), "byte offsets must be preserved");
1937 assert!(
1938 !out.contains("VACUUM"),
1939 "literal contents must be blanked: {out}"
1940 );
1941 assert!(
1942 out.contains("INSERT INTO t VALUES ("),
1943 "SQL outside the literal is untouched"
1944 );
1945 assert_eq!(out.matches('\'').count(), 2, "the quotes themselves stay");
1946 }
1947
1948 #[test]
1949 fn test_blank_string_literals_keeps_line_structure() {
1950 let sql = "INSERT INTO t VALUES ('line one\nline two');\nSELECT 1;";
1951 let out = blank_string_literals(sql);
1952 assert_eq!(out.len(), sql.len());
1953 assert_eq!(
1954 out.matches('\n').count(),
1955 sql.matches('\n').count(),
1956 "newlines inside literals must survive so line numbers stay right"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_blank_string_literals_leaves_quoted_identifiers_alone() {
1962 let sql = "CREATE TABLE \"my VACUUM table\" (id int);";
1964 let out = blank_string_literals(sql);
1965 assert!(out.contains("my VACUUM table"), "got: {out}");
1966 }
1967
1968 #[test]
1969 fn test_blank_string_literals_handles_doubled_quote_escape() {
1970 let sql = "SELECT 'it''s VACUUM time', 1;";
1971 let out = blank_string_literals(sql);
1972 assert_eq!(out.len(), sql.len());
1973 assert!(!out.contains("VACUUM"), "got: {out}");
1974 assert!(
1975 out.trim_end().ends_with(", 1;"),
1976 "parsing resumed too early: {out}"
1977 );
1978 }
1979
1980 #[test]
1981 fn test_blank_string_literals_ignores_apostrophe_in_comment() {
1982 let sql = "-- don't blank this\nSELECT 'x' FROM t;";
1985 let out = blank_string_literals(sql);
1986 assert_eq!(out.len(), sql.len());
1987 assert!(out.contains("FROM t;"), "got: {out}");
1988 }
1989
1990 #[test]
1991 fn test_ddl_keywords_inside_string_literals_are_not_operations() {
1992 for sql in [
1996 "INSERT INTO runbook (step) VALUES ('then DROP TABLE users');",
1997 "INSERT INTO n (m) VALUES ('remember to TRUNCATE TABLE orders');",
1998 "UPDATE notes SET body = 'ALTER TABLE t DROP COLUMN c' WHERE id = 1;",
1999 ] {
2000 let ops = extract_ddl_operations(sql);
2001 assert!(
2002 ops.iter().all(|o| matches!(o, DdlOperation::Other { .. })),
2003 "literal payload classified as DDL for {sql:?}: {ops:?}"
2004 );
2005 }
2006 }
2007
2008 #[test]
2009 fn test_real_ddl_is_still_detected_after_literal_blanking() {
2010 assert!(matches!(
2011 extract_ddl_operations("DROP TABLE users;").as_slice(),
2012 [DdlOperation::DropTable { table }] if table == "users"
2013 ));
2014 assert!(matches!(
2015 extract_ddl_operations("TRUNCATE TABLE orders;").as_slice(),
2016 [DdlOperation::TruncateTable { table }] if table == "orders"
2017 ));
2018 }
2019
2020 #[test]
2021 fn test_string_literal_default_survives_blanking() {
2022 match extract_ddl_operations("ALTER TABLE t ADD COLUMN c text DEFAULT 'busy waiting';")
2025 .as_slice()
2026 {
2027 [DdlOperation::AlterTableAddColumn { default_expr, .. }] => {
2028 assert_eq!(default_expr.as_deref(), Some("'busy waiting'"))
2029 }
2030 other => panic!("expected one AlterTableAddColumn, got {other:?}"),
2031 }
2032 }
2033}