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        ///
35        /// Determined from the parsed column definition only — a `DEFAULT`
36        /// appearing inside a `CHECK (...)` expression, a string literal, or
37        /// a comment does not count.
38        has_default: bool,
39        /// Whether the column has a NOT NULL constraint.
40        ///
41        /// Determined from the parsed column definition only — a `NOT NULL`
42        /// appearing inside a `CHECK (...)` expression, a string literal, or
43        /// a comment does not count.
44        is_not_null: bool,
45        /// Whether the clause includes `IF NOT EXISTS`.
46        if_not_exists: bool,
47        /// The `DEFAULT` expression of this column, if any.
48        default_expr: Option<String>,
49    },
50    /// An ALTER TABLE ... DROP COLUMN statement.
51    AlterTableDropColumn {
52        /// Name of the table being altered.
53        table: String,
54        /// Name of the column being dropped.
55        column: String,
56    },
57    /// An ALTER TABLE ... ALTER COLUMN statement.
58    AlterTableAlterColumn {
59        /// Name of the table being altered.
60        table: String,
61        /// Name of the column being modified.
62        column: String,
63    },
64    /// A CREATE INDEX statement.
65    CreateIndex {
66        /// Name of the index being created.
67        name: String,
68        /// Name of the table the index is on.
69        table: String,
70        /// Whether the index is created CONCURRENTLY.
71        is_concurrent: bool,
72        /// Whether this is a UNIQUE index.
73        is_unique: bool,
74    },
75    /// A DROP INDEX statement.
76    DropIndex {
77        /// Name of the index being dropped.
78        name: String,
79    },
80    /// A CREATE VIEW or CREATE MATERIALIZED VIEW statement.
81    CreateView {
82        /// Name of the view being created.
83        name: String,
84        /// Whether this is a materialized view.
85        is_materialized: bool,
86    },
87    /// A DROP VIEW statement.
88    DropView {
89        /// Name of the view being dropped.
90        name: String,
91    },
92    /// A CREATE FUNCTION statement.
93    CreateFunction {
94        /// Name of the function being created.
95        name: String,
96    },
97    /// A DROP FUNCTION statement.
98    DropFunction {
99        /// Name of the function being dropped.
100        name: String,
101    },
102    /// An ALTER TABLE ... ADD CONSTRAINT statement.
103    AddConstraint {
104        /// Name of the table the constraint is added to.
105        table: String,
106        /// Type of constraint (e.g. PRIMARY KEY, UNIQUE, FOREIGN KEY).
107        constraint_type: String,
108    },
109    /// An ALTER TABLE ... DROP CONSTRAINT statement.
110    DropConstraint {
111        /// Name of the table the constraint is dropped from.
112        table: String,
113        /// Name of the constraint being dropped.
114        name: String,
115    },
116    /// A CREATE TYPE ... AS ENUM statement.
117    CreateEnum {
118        /// Name of the enum type being created.
119        name: String,
120    },
121    /// A TRUNCATE TABLE statement.
122    TruncateTable {
123        /// Name of the table being truncated.
124        table: String,
125    },
126    /// Any other SQL statement that does not match known DDL patterns.
127    Other {
128        /// Truncated preview of the unrecognized statement.
129        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
215// Regex patterns for DDL extraction
216static 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/// A DDL operation together with its position in the source SQL.
279///
280/// Offsets are byte offsets into the **original** SQL text (comments
281/// included), so diagnostics can point at the statement that produced the
282/// operation even though the analysis itself runs on a comment-stripped copy.
283#[derive(Debug, Clone)]
284pub struct LocatedDdl {
285    /// The parsed operation.
286    pub op: DdlOperation,
287    /// Byte offset of the first character of the statement.
288    pub start: usize,
289    /// Byte offset just past the last character of the statement.
290    pub end: usize,
291    /// Byte offset of the token this operation is best anchored to: the
292    /// column name for `ADD COLUMN`, otherwise the leading keyword.
293    pub focus: usize,
294}
295
296/// Extract DDL operations from SQL content.
297///
298/// Comments are ignored: they can neither introduce nor suppress an
299/// operation, and keywords inside them (`NOT NULL`, `DEFAULT`, ...) never
300/// influence the parsed result.
301pub 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
308/// Extract DDL operations along with their source positions.
309pub fn extract_ddl_operations_located(sql: &str) -> Vec<LocatedDdl> {
310    // Comments *and* string literals are blanked before matching. The patterns
311    // in `parse_statement_ops` are unanchored, so without this an INSERT whose
312    // payload happens to mention DDL is classified as that DDL:
313    //
314    //   INSERT INTO runbook (step) VALUES ('then DROP TABLE users');
315    //     → DropTable { table: "users" }
316    //
317    // which made `safety` report a table drop that does not exist and, with
318    // `block_on_danger`, refuse the migration — pushing the operator towards
319    // `--force`, which then also disables the checks that are real.
320    //
321    // Both helpers preserve byte offsets and line breaks, so `start`, `end` and
322    // `focus` below still index the original `sql`, and the two copies can be
323    // used interchangeably by offset.
324    //
325    // `parse_statement_ops` takes both: the regex battery runs on the blanked
326    // text, while `parse_add_columns` gets the literals intact. That tokenizer
327    // is already literal-aware — it classifies `'…'` as a single string token,
328    // so it cannot be fooled — and it is the one branch that captures a value a
329    // literal may legitimately *be*: `default_expr` for
330    // `ADD COLUMN c text DEFAULT 'x'`. Blanking that would corrupt a public
331    // field of `DdlOperation`.
332    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            // Unrecognized statement — preview the original text so comments
341            // written inside the statement still show up verbatim.
342            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
372/// Parse a single comment-free statement into zero or more DDL operations.
373///
374/// Each operation is paired with the byte offset (relative to `stmt`) of the
375/// token it should be reported against.
376/// Classify one statement.
377///
378/// `stmt` has both comments and string-literal contents blanked and is what the
379/// unanchored regex patterns below match against. `stmt_with_literals` has only
380/// comments blanked, at identical byte offsets, and is used by the ADD COLUMN
381/// tokenizer so a `DEFAULT 'x'` survives verbatim.
382fn parse_statement_ops(stmt: &str, stmt_with_literals: &str) -> Vec<(DdlOperation, usize)> {
383    // Order matters — more specific patterns first
384
385    // ALTER TABLE ... ADD CONSTRAINT (before ADD COLUMN)
386    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    // ALTER TABLE ... DROP CONSTRAINT (before DROP COLUMN)
399    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    // ALTER TABLE ... ALTER COLUMN (before ADD/DROP COLUMN)
409    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    // ALTER TABLE ... DROP COLUMN
419    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    // ALTER TABLE ... ADD [COLUMN] [IF NOT EXISTS] <column> <type> [constraints]
429    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    // CREATE TABLE
450    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    // DROP TABLE
463    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    // CREATE INDEX
472    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    // DROP INDEX
489    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    // CREATE [MATERIALIZED] VIEW
498    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    // DROP VIEW
511    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    // CREATE FUNCTION
520    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    // DROP FUNCTION
529    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    // CREATE TYPE ... AS ENUM
538    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    // TRUNCATE
547    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// ---------------------------------------------------------------------------
559// ALTER TABLE ... ADD COLUMN parsing
560// ---------------------------------------------------------------------------
561
562/// A single `ADD [COLUMN] ...` clause parsed out of an `ALTER TABLE`.
563#[derive(Debug, Clone)]
564struct AddColumnClause {
565    column: String,
566    /// Byte offset of the column-name token within the statement.
567    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
575/// Keywords that terminate the data type and begin the constraint list of a
576/// column definition. `CHARACTER` is deliberately absent — `CHARACTER
577/// VARYING` and MySQL's `CHARACTER SET ...` both belong to the type.
578const 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
602/// Keywords that mean the token after `ADD` starts a table constraint rather
603/// than a column definition.
604const 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
617/// Parse every `ADD [COLUMN]` clause of an `ALTER TABLE` statement.
618///
619/// Handles the optional `COLUMN` keyword, the optional `IF NOT EXISTS`
620/// clause, schema-qualified and quoted identifiers, parenthesised types, and
621/// comma-separated clauses. `NOT NULL` and `DEFAULT` are only recognised at
622/// the top level of the column definition, so they are never picked up from
623/// inside a `CHECK (...)` expression or a string literal.
624///
625/// Returns `None` when the statement is not an `ALTER TABLE ... ADD <column>`.
626fn parse_add_columns(stmt: &str) -> Option<(String, Vec<AddColumnClause>)> {
627    let toks = tokenize(stmt);
628
629    // Locate `ALTER TABLE`.
630    let mut i = toks
631        .windows(2)
632        .position(|w| is_kw(&w[0], "ALTER") && is_kw(&w[1], "TABLE"))?
633        + 2;
634
635    // Optional PostgreSQL `ONLY` / `IF EXISTS` decorations.
636    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    // Qualified table name: ident ('.' ident)*
644    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    // PostgreSQL legacy inheritance marker: `ALTER TABLE parent * ...`
658    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    // Walk the action list, picking up every top-level `ADD` clause.
667    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
698/// Parse one `ADD [COLUMN] [IF NOT EXISTS] <column> <type> [constraints]`
699/// clause starting at token index `i` (the token just after `ADD`).
700///
701/// Returns the clause plus the index of the first token after it.
702fn 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    // `ADD CONSTRAINT ...`, `ADD PRIMARY KEY ...` etc. are not columns.
722    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    // Data type: everything up to the first top-level constraint keyword,
734    // clause-terminating comma, or end of statement.
735    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    // Constraint list: only top-level tokens count.
771    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
824/// Read the expression following a top-level `DEFAULT`, stopping at the next
825/// constraint keyword or the end of the column definition.
826///
827/// Returns the expression text and the index of the token after it. The first
828/// token is always consumed so that `DEFAULT NULL` keeps its value.
829fn 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
869/// Collapse runs of whitespace (including newlines) into single spaces.
870fn normalize_whitespace(s: &str) -> String {
871    s.split_whitespace().collect::<Vec<_>>().join(" ")
872}
873
874// ---------------------------------------------------------------------------
875// Tokenizer
876// ---------------------------------------------------------------------------
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879enum TokKind {
880    /// A bare word: keyword, unquoted identifier, or number.
881    Word,
882    /// A quoted identifier (`"col"` or `` `col` ``); `text` excludes the quotes.
883    Ident,
884    /// A string literal; `text` includes the quotes.
885    Literal,
886    /// A single punctuation character.
887    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
906/// Whether a token can stand in for an identifier (bare word or quoted).
907fn 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
917/// Split SQL into tokens. Comments and whitespace are skipped.
918fn 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
999/// Split SQL into individual statements, respecting dollar-quoted blocks,
1000/// string literals, quoted identifiers, and comments.
1001pub 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
1008/// Byte ranges of the individual statements in `sql`, each trimmed of
1009/// surrounding whitespace. Empty statements are skipped.
1010fn 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    // Remainder after the last semicolon
1038    if let Some(r) = trim_range(sql, start, len) {
1039        ranges.push(r);
1040    }
1041
1042    ranges
1043}
1044
1045/// Narrow `start..end` to the non-whitespace content it contains, or `None`
1046/// if it is entirely whitespace.
1047fn 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
1057/// If a comment starts at `i`, return the offset just past it.
1058///
1059/// Handles `-- line` comments (terminating before the newline) and nested
1060/// `/* block */` comments.
1061fn 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
1089/// If a quoted region starts at `i`, return the offset just past it.
1090///
1091/// Covers string literals (including `E'...'` escape strings and doubled-quote
1092/// escapes), double-quoted / backtick-quoted identifiers, and dollar-quoted
1093/// blocks.
1094fn 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            // E'...' escape strings honour backslash escapes.
1100            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; // doubled-quote escape
1112                    } 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; // doubled-quote escape
1128                        continue;
1129                    }
1130                    j += 1;
1131                    break;
1132                }
1133                j += 1;
1134            }
1135            Some(j.min(len))
1136        }
1137        // Dollar-quoted string ($$...$$, $tag$...$tag$)
1138        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
1161/// Blank out the *contents* of every string literal, preserving byte offsets,
1162/// line breaks and the surrounding quotes.
1163///
1164/// [`strip_comments`] skips over quoted regions but leaves their text in place,
1165/// so a keyword scan run on its output still sees words that are data rather
1166/// than SQL — `INSERT INTO runbook VALUES ('nightly VACUUM of orders')` read as
1167/// a `VACUUM` statement. Blanking the interior keeps every offset and line
1168/// number intact, so diagnostics still point at the original source.
1169///
1170/// Quoted *identifiers* (`"..."`, `` `...` ``) are deliberately left alone:
1171/// they name real objects and callers need to read them.
1172pub 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        // Comments first: an apostrophe inside a comment must not open a
1180        // literal (`-- don't do this`).
1181        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                // Keep the delimiters, blank what is between them.
1188                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    // Only ASCII bytes inside literals were replaced with ASCII spaces, so a
1202    // multi-byte character cannot be split; the result is still valid UTF-8.
1203    String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1204}
1205
1206/// Blank out every comment in `sql`, preserving byte offsets and line breaks.
1207///
1208/// Comment bytes become spaces (newlines are kept) so the result has exactly
1209/// the same length and line structure as the input. This lets semantic
1210/// analysis run on comment-free SQL while diagnostics still resolve to the
1211/// original source position.
1212pub 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    // Only ASCII comment bytes were replaced with ASCII spaces, so the result
1236    // is still valid UTF-8.
1237    String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1238}
1239
1240/// The 1-based line number containing the given byte offset.
1241pub 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
1249/// Split MySQL SQL into individual statements at top-level `;` terminators.
1250///
1251/// Respects single-quoted strings, double-quoted strings, backtick-quoted
1252/// identifiers, single-line `--` comments, and `/* ... */` block comments.
1253/// Does **not** handle MySQL's `DELIMITER //` blocks — stored-procedure DDL
1254/// that needs an alternate delimiter must be split by the caller (or
1255/// re-written without DELIMITER, which works for most ALTER/CREATE patterns).
1256///
1257/// Returns owned `String`s rather than borrowed slices so callers can pass
1258/// them directly to `mysql_async::query_drop` without lifetime gymnastics.
1259pub 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        // Line comment
1268        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        // Block comment
1275        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        // Single-quoted string
1284        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        // Double-quoted string
1297        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        // Backtick-quoted identifier
1310        if c == b'`' {
1311            i += 1;
1312            while i < len && bytes[i] != b'`' {
1313                i += 1;
1314            }
1315            i += 1;
1316            continue;
1317        }
1318        // Statement terminator
1319        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
1331/// Trim a candidate statement and push it only if it carries something the
1332/// server can execute.
1333///
1334/// MySQL rejects an empty or comment-only query with `ER_EMPTY_QUERY (1065)`,
1335/// so a file ending in a trailing comment (`... ; -- done`) or containing a
1336/// stray `;;` must not produce a statement here. We check for executable
1337/// content by blanking comments and seeing whether anything remains.
1338fn 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    /// Convenience: the single AddColumn op parsed out of `sql`.
1453    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        // Statement starts on line 3, the column token is on line 4.
1655        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        // A backtick-quoted identifier with `;` inside should NOT split.
1850        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        // The first chunk contains both the comment header and the CREATE TABLE.
1866        // Splitter doesn't emit comment-only fragments.
1867        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        // A file ending in a comment after the last `;` must not yield a
1891        // statement — MySQL answers ER_EMPTY_QUERY (1065).
1892        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        // Quoted identifiers name real objects; callers need to read them.
1963        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        // An apostrophe in a comment must not be read as opening a literal and
1983        // swallow the statement that follows.
1984        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        // An INSERT whose payload mentions DDL is an INSERT. Reading it as a
1993        // DROP made `safety` report a table drop that does not exist and, with
1994        // block_on_danger, refuse a valid migration.
1995        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        // `default_expr` is a public field, and a literal is a legitimate
2023        // default. The blanked copy is used for matching only.
2024        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}