Skip to main content

waypoint_core/
sql_parser.rs

1//! Lightweight regex-based DDL extraction from SQL content.
2//!
3//! Used by lint, changelog, and conflict detection features.
4
5use std::sync::LazyLock;
6
7use regex_lite::Regex;
8use serde::Serialize;
9
10/// A DDL operation extracted from SQL.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
12pub enum DdlOperation {
13    /// A CREATE TABLE statement.
14    CreateTable {
15        /// Name of the table being created.
16        table: String,
17        /// Whether the statement includes IF NOT EXISTS.
18        if_not_exists: bool,
19    },
20    /// A DROP TABLE statement.
21    DropTable {
22        /// Name of the table being dropped.
23        table: String,
24    },
25    /// An ALTER TABLE ... ADD COLUMN statement.
26    AlterTableAddColumn {
27        /// Name of the table being altered.
28        table: String,
29        /// Name of the column being added.
30        column: String,
31        /// Data type of the new column.
32        data_type: String,
33        /// Whether the column has a DEFAULT expression.
34        has_default: bool,
35        /// Whether the column has a NOT NULL constraint.
36        is_not_null: bool,
37    },
38    /// An ALTER TABLE ... DROP COLUMN statement.
39    AlterTableDropColumn {
40        /// Name of the table being altered.
41        table: String,
42        /// Name of the column being dropped.
43        column: String,
44    },
45    /// An ALTER TABLE ... ALTER COLUMN statement.
46    AlterTableAlterColumn {
47        /// Name of the table being altered.
48        table: String,
49        /// Name of the column being modified.
50        column: String,
51    },
52    /// A CREATE INDEX statement.
53    CreateIndex {
54        /// Name of the index being created.
55        name: String,
56        /// Name of the table the index is on.
57        table: String,
58        /// Whether the index is created CONCURRENTLY.
59        is_concurrent: bool,
60        /// Whether this is a UNIQUE index.
61        is_unique: bool,
62    },
63    /// A DROP INDEX statement.
64    DropIndex {
65        /// Name of the index being dropped.
66        name: String,
67    },
68    /// A CREATE VIEW or CREATE MATERIALIZED VIEW statement.
69    CreateView {
70        /// Name of the view being created.
71        name: String,
72        /// Whether this is a materialized view.
73        is_materialized: bool,
74    },
75    /// A DROP VIEW statement.
76    DropView {
77        /// Name of the view being dropped.
78        name: String,
79    },
80    /// A CREATE FUNCTION statement.
81    CreateFunction {
82        /// Name of the function being created.
83        name: String,
84    },
85    /// A DROP FUNCTION statement.
86    DropFunction {
87        /// Name of the function being dropped.
88        name: String,
89    },
90    /// An ALTER TABLE ... ADD CONSTRAINT statement.
91    AddConstraint {
92        /// Name of the table the constraint is added to.
93        table: String,
94        /// Type of constraint (e.g. PRIMARY KEY, UNIQUE, FOREIGN KEY).
95        constraint_type: String,
96    },
97    /// An ALTER TABLE ... DROP CONSTRAINT statement.
98    DropConstraint {
99        /// Name of the table the constraint is dropped from.
100        table: String,
101        /// Name of the constraint being dropped.
102        name: String,
103    },
104    /// A CREATE TYPE ... AS ENUM statement.
105    CreateEnum {
106        /// Name of the enum type being created.
107        name: String,
108    },
109    /// A TRUNCATE TABLE statement.
110    TruncateTable {
111        /// Name of the table being truncated.
112        table: String,
113    },
114    /// Any other SQL statement that does not match known DDL patterns.
115    Other {
116        /// Truncated preview of the unrecognized statement.
117        statement_preview: String,
118    },
119}
120
121impl std::fmt::Display for DdlOperation {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        match self {
124            DdlOperation::CreateTable {
125                table,
126                if_not_exists,
127            } => {
128                if *if_not_exists {
129                    write!(f, "CREATE TABLE IF NOT EXISTS {}", table)
130                } else {
131                    write!(f, "CREATE TABLE {}", table)
132                }
133            }
134            DdlOperation::DropTable { table } => write!(f, "DROP TABLE {}", table),
135            DdlOperation::AlterTableAddColumn {
136                table,
137                column,
138                data_type,
139                ..
140            } => {
141                write!(
142                    f,
143                    "ALTER TABLE {} ADD COLUMN {} {}",
144                    table, column, data_type
145                )
146            }
147            DdlOperation::AlterTableDropColumn { table, column } => {
148                write!(f, "ALTER TABLE {} DROP COLUMN {}", table, column)
149            }
150            DdlOperation::AlterTableAlterColumn { table, column } => {
151                write!(f, "ALTER TABLE {} ALTER COLUMN {}", table, column)
152            }
153            DdlOperation::CreateIndex {
154                name,
155                table,
156                is_unique,
157                is_concurrent,
158            } => {
159                let unique = if *is_unique { "UNIQUE " } else { "" };
160                let concurrent = if *is_concurrent { "CONCURRENTLY " } else { "" };
161                write!(
162                    f,
163                    "CREATE {}{}INDEX {} ON {}",
164                    unique, concurrent, name, table
165                )
166            }
167            DdlOperation::DropIndex { name } => write!(f, "DROP INDEX {}", name),
168            DdlOperation::CreateView {
169                name,
170                is_materialized,
171            } => {
172                if *is_materialized {
173                    write!(f, "CREATE MATERIALIZED VIEW {}", name)
174                } else {
175                    write!(f, "CREATE VIEW {}", name)
176                }
177            }
178            DdlOperation::DropView { name } => write!(f, "DROP VIEW {}", name),
179            DdlOperation::CreateFunction { name } => write!(f, "CREATE FUNCTION {}", name),
180            DdlOperation::DropFunction { name } => write!(f, "DROP FUNCTION {}", name),
181            DdlOperation::AddConstraint {
182                table,
183                constraint_type,
184            } => {
185                write!(
186                    f,
187                    "ALTER TABLE {} ADD {} CONSTRAINT",
188                    table, constraint_type
189                )
190            }
191            DdlOperation::DropConstraint { table, name } => {
192                write!(f, "ALTER TABLE {} DROP CONSTRAINT {}", table, name)
193            }
194            DdlOperation::CreateEnum { name } => write!(f, "CREATE TYPE {} AS ENUM", name),
195            DdlOperation::TruncateTable { table } => write!(f, "TRUNCATE TABLE {}", table),
196            DdlOperation::Other { statement_preview } => write!(f, "{}", statement_preview),
197        }
198    }
199}
200
201// Regex patterns for DDL extraction
202static CREATE_TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
203    Regex::new(r"(?i)CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
204});
205
206static DROP_TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
207    Regex::new(r"(?i)DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
208});
209
210static ALTER_TABLE_ADD_COLUMN_RE: LazyLock<Regex> = LazyLock::new(|| {
211    Regex::new(
212        r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+ADD\s+(?:COLUMN\s+)?(\w+)\s+(\w[\w\s\(\),]*)",
213    )
214    .unwrap()
215});
216
217static ALTER_TABLE_DROP_COLUMN_RE: LazyLock<Regex> = LazyLock::new(|| {
218    Regex::new(
219        r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+DROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(\w+)",
220    )
221    .unwrap()
222});
223
224static ALTER_TABLE_ALTER_COLUMN_RE: LazyLock<Regex> = LazyLock::new(|| {
225    Regex::new(r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+ALTER\s+(?:COLUMN\s+)?(\w+)").unwrap()
226});
227
228static CREATE_INDEX_RE: LazyLock<Regex> = LazyLock::new(|| {
229    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()
230});
231
232static DROP_INDEX_RE: LazyLock<Regex> = LazyLock::new(|| {
233    Regex::new(r"(?i)DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)")
234        .unwrap()
235});
236
237static CREATE_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
238    Regex::new(r"(?i)CREATE\s+(?:OR\s+REPLACE\s+)?(MATERIALIZED\s+)?VIEW\s+(?:(\w+)\.)?(\w+)")
239        .unwrap()
240});
241
242static DROP_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
243    Regex::new(r"(?i)DROP\s+(MATERIALIZED\s+)?VIEW\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
244});
245
246static CREATE_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
247    Regex::new(r"(?i)CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:(\w+)\.)?(\w+)").unwrap()
248});
249
250static DROP_FUNCTION_RE: LazyLock<Regex> = LazyLock::new(|| {
251    Regex::new(r"(?i)DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(?:(\w+)\.)?(\w+)").unwrap()
252});
253
254static ADD_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
255    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()
256});
257
258static DROP_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
259    Regex::new(
260        r"(?i)ALTER\s+TABLE\s+(?:(\w+)\.)?(\w+)\s+DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?(\w+)",
261    )
262    .unwrap()
263});
264
265static CREATE_ENUM_RE: LazyLock<Regex> =
266    LazyLock::new(|| Regex::new(r"(?i)CREATE\s+TYPE\s+(?:(\w+)\.)?(\w+)\s+AS\s+ENUM").unwrap());
267
268static TRUNCATE_RE: LazyLock<Regex> =
269    LazyLock::new(|| Regex::new(r"(?i)TRUNCATE\s+(?:TABLE\s+)?(?:(\w+)\.)?(\w+)").unwrap());
270
271/// Extract DDL operations from SQL content.
272pub fn extract_ddl_operations(sql: &str) -> Vec<DdlOperation> {
273    let statements = split_statements(sql);
274    let mut ops = Vec::new();
275
276    for stmt in statements {
277        let trimmed = stmt.trim();
278        if trimmed.is_empty() {
279            continue;
280        }
281
282        if let Some(op) = parse_statement(trimmed) {
283            ops.push(op);
284        }
285    }
286
287    ops
288}
289
290fn parse_statement(stmt: &str) -> Option<DdlOperation> {
291    // Order matters — more specific patterns first
292
293    // ALTER TABLE ... ADD CONSTRAINT (before ADD COLUMN)
294    if let Some(caps) = ADD_CONSTRAINT_RE.captures(stmt) {
295        let table = caps.get(2).unwrap().as_str().to_string();
296        let constraint_type = caps.get(3).unwrap().as_str().to_uppercase();
297        return Some(DdlOperation::AddConstraint {
298            table,
299            constraint_type,
300        });
301    }
302
303    // ALTER TABLE ... DROP CONSTRAINT (before DROP COLUMN)
304    if let Some(caps) = DROP_CONSTRAINT_RE.captures(stmt) {
305        let table = caps.get(2).unwrap().as_str().to_string();
306        let name = caps.get(3).unwrap().as_str().to_string();
307        return Some(DdlOperation::DropConstraint { table, name });
308    }
309
310    // ALTER TABLE ... ALTER COLUMN (before ADD/DROP COLUMN)
311    if ALTER_TABLE_ALTER_COLUMN_RE.is_match(stmt) {
312        if let Some(caps) = ALTER_TABLE_ALTER_COLUMN_RE.captures(stmt) {
313            let table = caps.get(2).unwrap().as_str().to_string();
314            let column = caps.get(3).unwrap().as_str().to_string();
315            return Some(DdlOperation::AlterTableAlterColumn { table, column });
316        }
317    }
318
319    // ALTER TABLE ... DROP COLUMN
320    if let Some(caps) = ALTER_TABLE_DROP_COLUMN_RE.captures(stmt) {
321        let table = caps.get(2).unwrap().as_str().to_string();
322        let column = caps.get(3).unwrap().as_str().to_string();
323        return Some(DdlOperation::AlterTableDropColumn { table, column });
324    }
325
326    // ALTER TABLE ... ADD COLUMN
327    if let Some(caps) = ALTER_TABLE_ADD_COLUMN_RE.captures(stmt) {
328        let table = caps.get(2).unwrap().as_str().to_string();
329        let column = caps.get(3).unwrap().as_str().to_string();
330        let rest = caps.get(4).unwrap().as_str();
331        // Extract data type (first word)
332        let data_type = rest
333            .split_whitespace()
334            .next()
335            .unwrap_or("unknown")
336            .to_string();
337        let upper = stmt.to_uppercase();
338        let has_default = upper.contains("DEFAULT");
339        let is_not_null = upper.contains("NOT NULL");
340        return Some(DdlOperation::AlterTableAddColumn {
341            table,
342            column,
343            data_type,
344            has_default,
345            is_not_null,
346        });
347    }
348
349    // CREATE TABLE
350    if let Some(caps) = CREATE_TABLE_RE.captures(stmt) {
351        let if_not_exists = caps.get(1).is_some();
352        let table = caps.get(3).unwrap().as_str().to_string();
353        return Some(DdlOperation::CreateTable {
354            table,
355            if_not_exists,
356        });
357    }
358
359    // DROP TABLE
360    if let Some(caps) = DROP_TABLE_RE.captures(stmt) {
361        let table = caps.get(2).unwrap().as_str().to_string();
362        return Some(DdlOperation::DropTable { table });
363    }
364
365    // CREATE INDEX
366    if let Some(caps) = CREATE_INDEX_RE.captures(stmt) {
367        let is_unique = caps.get(1).is_some();
368        let is_concurrent = caps.get(2).is_some();
369        let name = caps.get(3).unwrap().as_str().to_string();
370        let table = caps.get(5).unwrap().as_str().to_string();
371        return Some(DdlOperation::CreateIndex {
372            name,
373            table,
374            is_concurrent,
375            is_unique,
376        });
377    }
378
379    // DROP INDEX
380    if let Some(caps) = DROP_INDEX_RE.captures(stmt) {
381        let name = caps.get(2).unwrap().as_str().to_string();
382        return Some(DdlOperation::DropIndex { name });
383    }
384
385    // CREATE [MATERIALIZED] VIEW
386    if let Some(caps) = CREATE_VIEW_RE.captures(stmt) {
387        let is_materialized = caps.get(1).is_some();
388        let name = caps.get(3).unwrap().as_str().to_string();
389        return Some(DdlOperation::CreateView {
390            name,
391            is_materialized,
392        });
393    }
394
395    // DROP VIEW
396    if let Some(caps) = DROP_VIEW_RE.captures(stmt) {
397        let name = caps.get(3).unwrap().as_str().to_string();
398        return Some(DdlOperation::DropView { name });
399    }
400
401    // CREATE FUNCTION
402    if let Some(caps) = CREATE_FUNCTION_RE.captures(stmt) {
403        let name = caps.get(2).unwrap().as_str().to_string();
404        return Some(DdlOperation::CreateFunction { name });
405    }
406
407    // DROP FUNCTION
408    if let Some(caps) = DROP_FUNCTION_RE.captures(stmt) {
409        let name = caps.get(2).unwrap().as_str().to_string();
410        return Some(DdlOperation::DropFunction { name });
411    }
412
413    // CREATE TYPE ... AS ENUM
414    if let Some(caps) = CREATE_ENUM_RE.captures(stmt) {
415        let name = caps.get(2).unwrap().as_str().to_string();
416        return Some(DdlOperation::CreateEnum { name });
417    }
418
419    // TRUNCATE
420    if let Some(caps) = TRUNCATE_RE.captures(stmt) {
421        let table = caps.get(2).unwrap().as_str().to_string();
422        return Some(DdlOperation::TruncateTable { table });
423    }
424
425    // Check if it looks like a DDL/DML statement (not just a comment)
426    let upper = stmt.trim_start().to_uppercase();
427    if upper.starts_with("--") || upper.is_empty() {
428        return None;
429    }
430
431    // Produce an "Other" for non-trivial statements
432    let preview: String = stmt.chars().take(80).collect();
433    let preview = if stmt.len() > 80 {
434        format!("{}...", preview)
435    } else {
436        preview
437    };
438    Some(DdlOperation::Other {
439        statement_preview: preview,
440    })
441}
442
443/// Split SQL into individual statements, respecting dollar-quoted blocks,
444/// string literals, and comments.
445pub fn split_statements(sql: &str) -> Vec<&str> {
446    let bytes = sql.as_bytes();
447    let len = bytes.len();
448    let mut statements = Vec::new();
449    let mut start = 0;
450    let mut i = 0;
451
452    while i < len {
453        match bytes[i] {
454            // Single-line comment
455            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
456                // Skip to end of line
457                while i < len && bytes[i] != b'\n' {
458                    i += 1;
459                }
460            }
461            // Block comment
462            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
463                i += 2;
464                let mut depth = 1;
465                while i < len && depth > 0 {
466                    if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
467                        depth += 1;
468                        i += 2;
469                    } else if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'/' {
470                        depth -= 1;
471                        i += 2;
472                    } else {
473                        i += 1;
474                    }
475                }
476                continue;
477            }
478            // String literal (standard or E'...' escape string)
479            b'\'' => {
480                // Check if this is an E'...' escape string
481                let is_escape_string = i > 0
482                    && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
483                    && (i < 2 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_'));
484                i += 1;
485                while i < len {
486                    if is_escape_string && bytes[i] == b'\\' {
487                        i += 2; // Skip escaped character in E-string
488                        continue;
489                    }
490                    if bytes[i] == b'\'' {
491                        if i + 1 < len && bytes[i + 1] == b'\'' {
492                            i += 2; // doubled-quote escape
493                        } else {
494                            i += 1;
495                            break;
496                        }
497                    } else {
498                        i += 1;
499                    }
500                }
501                continue;
502            }
503            // Dollar-quoted string ($$...$$, $tag$...$tag$)
504            b'$' => {
505                // Find the tag
506                let tag_start = i;
507                i += 1;
508                while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
509                    i += 1;
510                }
511                if i < len && bytes[i] == b'$' {
512                    let tag = &sql[tag_start..=i];
513                    i += 1;
514                    // Find closing tag
515                    loop {
516                        if i >= len {
517                            break;
518                        }
519                        if bytes[i] == b'$' {
520                            let remaining = &sql[i..];
521                            if remaining.starts_with(tag) {
522                                i += tag.len();
523                                break;
524                            }
525                        }
526                        i += 1;
527                    }
528                }
529                continue;
530            }
531            // Statement separator
532            b';' => {
533                let stmt = &sql[start..i];
534                let trimmed = stmt.trim();
535                if !trimmed.is_empty() {
536                    statements.push(trimmed);
537                }
538                i += 1;
539                start = i;
540                continue;
541            }
542            _ => {}
543        }
544        i += 1;
545    }
546
547    // Remainder after last semicolon
548    let remainder = sql[start..].trim();
549    if !remainder.is_empty() {
550        statements.push(remainder);
551    }
552
553    statements
554}
555
556/// Count the approximate line number for a byte offset.
557pub fn line_number_at(sql: &str, offset: usize) -> usize {
558    sql[..offset.min(sql.len())].lines().count()
559}
560
561/// Split MySQL SQL into individual statements at top-level `;` terminators.
562///
563/// Respects single-quoted strings, double-quoted strings, backtick-quoted
564/// identifiers, single-line `--` comments, and `/* ... */` block comments.
565/// Does **not** handle MySQL's `DELIMITER //` blocks — stored-procedure DDL
566/// that needs an alternate delimiter must be split by the caller (or
567/// re-written without DELIMITER, which works for most ALTER/CREATE patterns).
568///
569/// Returns owned `String`s rather than borrowed slices so callers can pass
570/// them directly to `mysql_async::query_drop` without lifetime gymnastics.
571pub fn split_mysql_statements(sql: &str) -> Vec<String> {
572    let bytes = sql.as_bytes();
573    let len = bytes.len();
574    let mut out = Vec::new();
575    let mut start = 0;
576    let mut i = 0;
577    while i < len {
578        let c = bytes[i];
579        // Line comment
580        if c == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
581            while i < len && bytes[i] != b'\n' {
582                i += 1;
583            }
584            continue;
585        }
586        // Block comment
587        if c == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
588            i += 2;
589            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
590                i += 1;
591            }
592            i = (i + 2).min(len);
593            continue;
594        }
595        // Single-quoted string
596        if c == b'\'' {
597            i += 1;
598            while i < len && bytes[i] != b'\'' {
599                if bytes[i] == b'\\' && i + 1 < len {
600                    i += 2;
601                } else {
602                    i += 1;
603                }
604            }
605            i += 1;
606            continue;
607        }
608        // Double-quoted string
609        if c == b'"' {
610            i += 1;
611            while i < len && bytes[i] != b'"' {
612                if bytes[i] == b'\\' && i + 1 < len {
613                    i += 2;
614                } else {
615                    i += 1;
616                }
617            }
618            i += 1;
619            continue;
620        }
621        // Backtick-quoted identifier
622        if c == b'`' {
623            i += 1;
624            while i < len && bytes[i] != b'`' {
625                i += 1;
626            }
627            i += 1;
628            continue;
629        }
630        // Statement terminator
631        if c == b';' {
632            out.push(sql[start..i].to_string());
633            i += 1;
634            start = i;
635            continue;
636        }
637        i += 1;
638    }
639    let tail = sql[start..].trim();
640    if !tail.is_empty() {
641        out.push(tail.to_string());
642    }
643    out
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_split_simple_statements() {
652        let sql = "SELECT 1; SELECT 2;";
653        let stmts = split_statements(sql);
654        assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
655    }
656
657    #[test]
658    fn test_split_respects_string_literals() {
659        let sql = "SELECT 'hello;world'; SELECT 2;";
660        let stmts = split_statements(sql);
661        assert_eq!(stmts, vec!["SELECT 'hello;world'", "SELECT 2"]);
662    }
663
664    #[test]
665    fn test_split_respects_dollar_quoting() {
666        let sql =
667            "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql; SELECT 1;";
668        let stmts = split_statements(sql);
669        assert_eq!(stmts.len(), 2);
670        assert!(stmts[0].contains("BEGIN; END;"));
671    }
672
673    #[test]
674    fn test_split_respects_tagged_dollar_quoting() {
675        let sql = "CREATE FUNCTION foo() RETURNS void AS $body$ BEGIN; END; $body$ LANGUAGE plpgsql; SELECT 1;";
676        let stmts = split_statements(sql);
677        assert_eq!(stmts.len(), 2);
678        assert!(stmts[0].contains("BEGIN; END;"));
679    }
680
681    #[test]
682    fn test_split_respects_comments() {
683        let sql = "-- This is a comment with ; semicolon\nSELECT 1;";
684        let stmts = split_statements(sql);
685        assert_eq!(stmts.len(), 1);
686    }
687
688    #[test]
689    fn test_split_no_trailing_semicolon() {
690        let sql = "SELECT 1";
691        let stmts = split_statements(sql);
692        assert_eq!(stmts, vec!["SELECT 1"]);
693    }
694
695    #[test]
696    fn test_extract_create_table() {
697        let sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);";
698        let ops = extract_ddl_operations(sql);
699        assert_eq!(ops.len(), 1);
700        match &ops[0] {
701            DdlOperation::CreateTable {
702                table,
703                if_not_exists,
704            } => {
705                assert_eq!(table, "users");
706                assert!(!if_not_exists);
707            }
708            _ => panic!("Expected CreateTable"),
709        }
710    }
711
712    #[test]
713    fn test_extract_create_table_if_not_exists() {
714        let sql = "CREATE TABLE IF NOT EXISTS users (id SERIAL);";
715        let ops = extract_ddl_operations(sql);
716        match &ops[0] {
717            DdlOperation::CreateTable {
718                table,
719                if_not_exists,
720            } => {
721                assert_eq!(table, "users");
722                assert!(if_not_exists);
723            }
724            _ => panic!("Expected CreateTable"),
725        }
726    }
727
728    #[test]
729    fn test_extract_add_column() {
730        let sql = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';";
731        let ops = extract_ddl_operations(sql);
732        match &ops[0] {
733            DdlOperation::AlterTableAddColumn {
734                table,
735                column,
736                is_not_null,
737                has_default,
738                ..
739            } => {
740                assert_eq!(table, "users");
741                assert_eq!(column, "email");
742                assert!(is_not_null);
743                assert!(has_default);
744            }
745            _ => panic!("Expected AlterTableAddColumn"),
746        }
747    }
748
749    #[test]
750    fn test_extract_create_index() {
751        let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email);";
752        let ops = extract_ddl_operations(sql);
753        match &ops[0] {
754            DdlOperation::CreateIndex {
755                name,
756                table,
757                is_concurrent,
758                is_unique,
759            } => {
760                assert_eq!(name, "idx_users_email");
761                assert_eq!(table, "users");
762                assert!(is_concurrent);
763                assert!(is_unique);
764            }
765            _ => panic!("Expected CreateIndex"),
766        }
767    }
768
769    #[test]
770    fn test_extract_create_function() {
771        let sql = "CREATE OR REPLACE FUNCTION my_func() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;";
772        let ops = extract_ddl_operations(sql);
773        match &ops[0] {
774            DdlOperation::CreateFunction { name } => {
775                assert_eq!(name, "my_func");
776            }
777            _ => panic!("Expected CreateFunction, got {:?}", ops[0]),
778        }
779    }
780
781    #[test]
782    fn test_extract_create_enum() {
783        let sql = "CREATE TYPE mood AS ENUM ('happy', 'sad');";
784        let ops = extract_ddl_operations(sql);
785        match &ops[0] {
786            DdlOperation::CreateEnum { name } => {
787                assert_eq!(name, "mood");
788            }
789            _ => panic!("Expected CreateEnum"),
790        }
791    }
792
793    #[test]
794    fn test_extract_multiple() {
795        let sql = "CREATE TABLE users (id SERIAL); CREATE INDEX idx_users ON users (id); DROP TABLE old_table;";
796        let ops = extract_ddl_operations(sql);
797        assert_eq!(ops.len(), 3);
798    }
799
800    #[test]
801    fn test_extract_truncate() {
802        let sql = "TRUNCATE TABLE users;";
803        let ops = extract_ddl_operations(sql);
804        match &ops[0] {
805            DdlOperation::TruncateTable { table } => assert_eq!(table, "users"),
806            _ => panic!("Expected TruncateTable"),
807        }
808    }
809
810    #[test]
811    fn test_extract_drop_column() {
812        let sql = "ALTER TABLE users DROP COLUMN email;";
813        let ops = extract_ddl_operations(sql);
814        match &ops[0] {
815            DdlOperation::AlterTableDropColumn { table, column } => {
816                assert_eq!(table, "users");
817                assert_eq!(column, "email");
818            }
819            _ => panic!("Expected AlterTableDropColumn"),
820        }
821    }
822
823    #[test]
824    fn test_extract_alter_column() {
825        let sql = "ALTER TABLE users ALTER COLUMN name TYPE text;";
826        let ops = extract_ddl_operations(sql);
827        match &ops[0] {
828            DdlOperation::AlterTableAlterColumn { table, column } => {
829                assert_eq!(table, "users");
830                assert_eq!(column, "name");
831            }
832            _ => panic!("Expected AlterTableAlterColumn"),
833        }
834    }
835
836    #[test]
837    fn test_extract_materialized_view() {
838        let sql = "CREATE MATERIALIZED VIEW user_stats AS SELECT count(*) FROM users;";
839        let ops = extract_ddl_operations(sql);
840        match &ops[0] {
841            DdlOperation::CreateView {
842                name,
843                is_materialized,
844            } => {
845                assert_eq!(name, "user_stats");
846                assert!(is_materialized);
847            }
848            _ => panic!("Expected CreateView"),
849        }
850    }
851
852    #[test]
853    fn test_block_comment_with_semicolons() {
854        let sql = "/* comment; with; semicolons */ SELECT 1;";
855        let stmts = split_statements(sql);
856        assert_eq!(stmts.len(), 1);
857    }
858
859    #[test]
860    fn test_escaped_string_quotes() {
861        let sql = "SELECT 'it''s; here'; SELECT 2;";
862        let stmts = split_statements(sql);
863        assert_eq!(stmts.len(), 2);
864    }
865
866    #[test]
867    fn test_split_respects_e_escape_strings() {
868        let sql = r"SELECT E'hello\';world'; SELECT 2;";
869        let stmts = split_statements(sql);
870        assert_eq!(stmts.len(), 2);
871        assert!(stmts[0].contains(r"E'hello\';world'"));
872    }
873
874    #[test]
875    fn test_split_e_string_with_backslash() {
876        let sql = r"SELECT E'it\'s a test; really'; SELECT 1;";
877        let stmts = split_statements(sql);
878        assert_eq!(stmts.len(), 2);
879    }
880
881    #[test]
882    fn test_split_nested_block_comments() {
883        let sql = "SELECT /* outer /* inner */ outer */ 1; SELECT 2;";
884        let stmts = split_statements(sql);
885        assert_eq!(stmts.len(), 2);
886        assert_eq!(stmts[1], "SELECT 2");
887    }
888
889    #[test]
890    fn test_split_whitespace_only() {
891        let stmts = split_statements("   \n\t  ");
892        assert!(stmts.is_empty());
893    }
894
895    #[test]
896    fn test_split_comment_only() {
897        let stmts = split_statements("-- just a comment\n");
898        assert_eq!(stmts.len(), 1);
899        assert_eq!(stmts[0], "-- just a comment");
900    }
901
902    #[test]
903    fn test_split_mixed_e_and_regular_strings() {
904        let sql = r"SELECT 'normal;string', E'escape\';string'; SELECT 2;";
905        let stmts = split_statements(sql);
906        assert_eq!(stmts.len(), 2);
907    }
908
909    #[test]
910    fn test_split_mysql_basic() {
911        let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
912        let stmts = split_mysql_statements(sql);
913        assert_eq!(stmts.len(), 2);
914        assert!(stmts[0].contains("CREATE TABLE a"));
915        assert!(stmts[1].contains("CREATE TABLE b"));
916    }
917
918    #[test]
919    fn test_split_mysql_respects_backticks_with_semicolons() {
920        // A backtick-quoted identifier with `;` inside should NOT split.
921        let sql = "CREATE TABLE `weird;name` (id INT); CREATE TABLE b (id INT);";
922        let stmts = split_mysql_statements(sql);
923        assert_eq!(stmts.len(), 2);
924        assert!(stmts[0].contains("`weird;name`"));
925    }
926
927    #[test]
928    fn test_split_mysql_respects_string_literals_with_semicolons() {
929        let sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c;d');";
930        let stmts = split_mysql_statements(sql);
931        assert_eq!(stmts.len(), 2);
932    }
933
934    #[test]
935    fn test_split_mysql_keeps_leading_comments_with_statement() {
936        // The first chunk contains both the comment header and the CREATE TABLE.
937        // Splitter doesn't emit comment-only fragments.
938        let sql = "-- header comment\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
939        let stmts = split_mysql_statements(sql);
940        assert_eq!(stmts.len(), 2);
941        assert!(stmts[0].contains("CREATE TABLE a"));
942    }
943
944    #[test]
945    fn test_split_mysql_handles_block_comments() {
946        let sql = "/* block ; comment */ CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
947        let stmts = split_mysql_statements(sql);
948        assert_eq!(stmts.len(), 2);
949    }
950
951    #[test]
952    fn test_split_mysql_no_trailing_semicolon() {
953        let sql = "CREATE TABLE a (id INT)";
954        let stmts = split_mysql_statements(sql);
955        assert_eq!(stmts.len(), 1);
956        assert!(stmts[0].contains("CREATE TABLE a"));
957    }
958}