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    let stripped = strip_comments(sql);
311    let mut ops = Vec::new();
312
313    for (start, end) in statement_ranges(&stripped) {
314        let stmt = &stripped[start..end];
315        let parsed = parse_statement_ops(stmt);
316        if parsed.is_empty() {
317            // Unrecognized statement — preview the original text so comments
318            // written inside the statement still show up verbatim.
319            let raw = &sql[start..end];
320            let preview: String = raw.chars().take(80).collect();
321            let preview = if raw.len() > 80 {
322                format!("{}...", preview)
323            } else {
324                preview
325            };
326            ops.push(LocatedDdl {
327                op: DdlOperation::Other {
328                    statement_preview: preview,
329                },
330                start,
331                end,
332                focus: start,
333            });
334            continue;
335        }
336        for (op, offset) in parsed {
337            ops.push(LocatedDdl {
338                op,
339                start,
340                end,
341                focus: start + offset,
342            });
343        }
344    }
345
346    ops
347}
348
349/// Parse a single comment-free statement into zero or more DDL operations.
350///
351/// Each operation is paired with the byte offset (relative to `stmt`) of the
352/// token it should be reported against.
353fn parse_statement_ops(stmt: &str) -> Vec<(DdlOperation, usize)> {
354    // Order matters — more specific patterns first
355
356    // ALTER TABLE ... ADD CONSTRAINT (before ADD COLUMN)
357    if let Some(caps) = ADD_CONSTRAINT_RE.captures(stmt) {
358        let table = caps.get(2).unwrap().as_str().to_string();
359        let constraint_type = caps.get(3).unwrap().as_str().to_uppercase();
360        return vec![(
361            DdlOperation::AddConstraint {
362                table,
363                constraint_type,
364            },
365            caps.get(0).unwrap().start(),
366        )];
367    }
368
369    // ALTER TABLE ... DROP CONSTRAINT (before DROP COLUMN)
370    if let Some(caps) = DROP_CONSTRAINT_RE.captures(stmt) {
371        let table = caps.get(2).unwrap().as_str().to_string();
372        let name = caps.get(3).unwrap().as_str().to_string();
373        return vec![(
374            DdlOperation::DropConstraint { table, name },
375            caps.get(0).unwrap().start(),
376        )];
377    }
378
379    // ALTER TABLE ... ALTER COLUMN (before ADD/DROP COLUMN)
380    if let Some(caps) = ALTER_TABLE_ALTER_COLUMN_RE.captures(stmt) {
381        let table = caps.get(2).unwrap().as_str().to_string();
382        let column = caps.get(3).unwrap().as_str().to_string();
383        return vec![(
384            DdlOperation::AlterTableAlterColumn { table, column },
385            caps.get(0).unwrap().start(),
386        )];
387    }
388
389    // ALTER TABLE ... DROP COLUMN
390    if let Some(caps) = ALTER_TABLE_DROP_COLUMN_RE.captures(stmt) {
391        let table = caps.get(2).unwrap().as_str().to_string();
392        let column = caps.get(3).unwrap().as_str().to_string();
393        return vec![(
394            DdlOperation::AlterTableDropColumn { table, column },
395            caps.get(0).unwrap().start(),
396        )];
397    }
398
399    // ALTER TABLE ... ADD [COLUMN] [IF NOT EXISTS] <column> <type> [constraints]
400    if let Some((table, clauses)) = parse_add_columns(stmt) {
401        return clauses
402            .into_iter()
403            .map(|c| {
404                (
405                    DdlOperation::AlterTableAddColumn {
406                        table: table.clone(),
407                        column: c.column,
408                        data_type: c.data_type,
409                        has_default: c.has_default,
410                        is_not_null: c.is_not_null,
411                        if_not_exists: c.if_not_exists,
412                        default_expr: c.default_expr,
413                    },
414                    c.column_offset,
415                )
416            })
417            .collect();
418    }
419
420    // CREATE TABLE
421    if let Some(caps) = CREATE_TABLE_RE.captures(stmt) {
422        let if_not_exists = caps.get(1).is_some();
423        let table = caps.get(3).unwrap().as_str().to_string();
424        return vec![(
425            DdlOperation::CreateTable {
426                table,
427                if_not_exists,
428            },
429            caps.get(0).unwrap().start(),
430        )];
431    }
432
433    // DROP TABLE
434    if let Some(caps) = DROP_TABLE_RE.captures(stmt) {
435        let table = caps.get(2).unwrap().as_str().to_string();
436        return vec![(
437            DdlOperation::DropTable { table },
438            caps.get(0).unwrap().start(),
439        )];
440    }
441
442    // CREATE INDEX
443    if let Some(caps) = CREATE_INDEX_RE.captures(stmt) {
444        let is_unique = caps.get(1).is_some();
445        let is_concurrent = caps.get(2).is_some();
446        let name = caps.get(3).unwrap().as_str().to_string();
447        let table = caps.get(5).unwrap().as_str().to_string();
448        return vec![(
449            DdlOperation::CreateIndex {
450                name,
451                table,
452                is_concurrent,
453                is_unique,
454            },
455            caps.get(0).unwrap().start(),
456        )];
457    }
458
459    // DROP INDEX
460    if let Some(caps) = DROP_INDEX_RE.captures(stmt) {
461        let name = caps.get(2).unwrap().as_str().to_string();
462        return vec![(
463            DdlOperation::DropIndex { name },
464            caps.get(0).unwrap().start(),
465        )];
466    }
467
468    // CREATE [MATERIALIZED] VIEW
469    if let Some(caps) = CREATE_VIEW_RE.captures(stmt) {
470        let is_materialized = caps.get(1).is_some();
471        let name = caps.get(3).unwrap().as_str().to_string();
472        return vec![(
473            DdlOperation::CreateView {
474                name,
475                is_materialized,
476            },
477            caps.get(0).unwrap().start(),
478        )];
479    }
480
481    // DROP VIEW
482    if let Some(caps) = DROP_VIEW_RE.captures(stmt) {
483        let name = caps.get(3).unwrap().as_str().to_string();
484        return vec![(
485            DdlOperation::DropView { name },
486            caps.get(0).unwrap().start(),
487        )];
488    }
489
490    // CREATE FUNCTION
491    if let Some(caps) = CREATE_FUNCTION_RE.captures(stmt) {
492        let name = caps.get(2).unwrap().as_str().to_string();
493        return vec![(
494            DdlOperation::CreateFunction { name },
495            caps.get(0).unwrap().start(),
496        )];
497    }
498
499    // DROP FUNCTION
500    if let Some(caps) = DROP_FUNCTION_RE.captures(stmt) {
501        let name = caps.get(2).unwrap().as_str().to_string();
502        return vec![(
503            DdlOperation::DropFunction { name },
504            caps.get(0).unwrap().start(),
505        )];
506    }
507
508    // CREATE TYPE ... AS ENUM
509    if let Some(caps) = CREATE_ENUM_RE.captures(stmt) {
510        let name = caps.get(2).unwrap().as_str().to_string();
511        return vec![(
512            DdlOperation::CreateEnum { name },
513            caps.get(0).unwrap().start(),
514        )];
515    }
516
517    // TRUNCATE
518    if let Some(caps) = TRUNCATE_RE.captures(stmt) {
519        let table = caps.get(2).unwrap().as_str().to_string();
520        return vec![(
521            DdlOperation::TruncateTable { table },
522            caps.get(0).unwrap().start(),
523        )];
524    }
525
526    Vec::new()
527}
528
529// ---------------------------------------------------------------------------
530// ALTER TABLE ... ADD COLUMN parsing
531// ---------------------------------------------------------------------------
532
533/// A single `ADD [COLUMN] ...` clause parsed out of an `ALTER TABLE`.
534#[derive(Debug, Clone)]
535struct AddColumnClause {
536    column: String,
537    /// Byte offset of the column-name token within the statement.
538    column_offset: usize,
539    data_type: String,
540    if_not_exists: bool,
541    is_not_null: bool,
542    has_default: bool,
543    default_expr: Option<String>,
544}
545
546/// Keywords that terminate the data type and begin the constraint list of a
547/// column definition. `CHARACTER` is deliberately absent — `CHARACTER
548/// VARYING` and MySQL's `CHARACTER SET ...` both belong to the type.
549const COLUMN_CONSTRAINT_KEYWORDS: &[&str] = &[
550    "NOT",
551    "NULL",
552    "DEFAULT",
553    "CHECK",
554    "UNIQUE",
555    "PRIMARY",
556    "REFERENCES",
557    "CONSTRAINT",
558    "GENERATED",
559    "COLLATE",
560    "DEFERRABLE",
561    "INITIALLY",
562    "COMMENT",
563    "AUTO_INCREMENT",
564    "IDENTITY",
565    "STORAGE",
566    "COMPRESSION",
567    "VISIBLE",
568    "INVISIBLE",
569    "FIRST",
570    "AFTER",
571];
572
573/// Keywords that mean the token after `ADD` starts a table constraint rather
574/// than a column definition.
575const TABLE_CONSTRAINT_KEYWORDS: &[&str] = &[
576    "CONSTRAINT",
577    "PRIMARY",
578    "UNIQUE",
579    "FOREIGN",
580    "CHECK",
581    "EXCLUDE",
582    "INDEX",
583    "KEY",
584    "FULLTEXT",
585    "SPATIAL",
586];
587
588/// Parse every `ADD [COLUMN]` clause of an `ALTER TABLE` statement.
589///
590/// Handles the optional `COLUMN` keyword, the optional `IF NOT EXISTS`
591/// clause, schema-qualified and quoted identifiers, parenthesised types, and
592/// comma-separated clauses. `NOT NULL` and `DEFAULT` are only recognised at
593/// the top level of the column definition, so they are never picked up from
594/// inside a `CHECK (...)` expression or a string literal.
595///
596/// Returns `None` when the statement is not an `ALTER TABLE ... ADD <column>`.
597fn parse_add_columns(stmt: &str) -> Option<(String, Vec<AddColumnClause>)> {
598    let toks = tokenize(stmt);
599
600    // Locate `ALTER TABLE`.
601    let mut i = toks
602        .windows(2)
603        .position(|w| is_kw(&w[0], "ALTER") && is_kw(&w[1], "TABLE"))?
604        + 2;
605
606    // Optional PostgreSQL `ONLY` / `IF EXISTS` decorations.
607    if kw_at(&toks, i, "IF") && kw_at(&toks, i + 1, "EXISTS") {
608        i += 2;
609    }
610    if kw_at(&toks, i, "ONLY") {
611        i += 1;
612    }
613
614    // Qualified table name: ident ('.' ident)*
615    let mut name_parts: Vec<&str> = Vec::new();
616    loop {
617        let t = toks.get(i)?;
618        if !is_identifier(t) {
619            return None;
620        }
621        name_parts.push(t.text);
622        i += 1;
623        match toks.get(i) {
624            Some(t) if t.kind == TokKind::Punct && t.text == "." => i += 1,
625            _ => break,
626        }
627    }
628    // PostgreSQL legacy inheritance marker: `ALTER TABLE parent * ...`
629    if toks
630        .get(i)
631        .is_some_and(|t| t.kind == TokKind::Punct && t.text == "*")
632    {
633        i += 1;
634    }
635    let table = (*name_parts.last()?).to_string();
636
637    // Walk the action list, picking up every top-level `ADD` clause.
638    let mut clauses = Vec::new();
639    let mut depth = 0usize;
640    while i < toks.len() {
641        let t = &toks[i];
642        if t.kind == TokKind::Punct {
643            match t.text {
644                "(" => depth += 1,
645                ")" => depth = depth.saturating_sub(1),
646                _ => {}
647            }
648            i += 1;
649            continue;
650        }
651        if depth == 0 && is_kw(t, "ADD") {
652            if let Some((clause, next)) = parse_one_add_column(stmt, &toks, i + 1) {
653                clauses.push(clause);
654                i = next;
655                continue;
656            }
657        }
658        i += 1;
659    }
660
661    if clauses.is_empty() {
662        None
663    } else {
664        Some((table, clauses))
665    }
666}
667
668/// Parse one `ADD [COLUMN] [IF NOT EXISTS] <column> <type> [constraints]`
669/// clause starting at token index `i` (the token just after `ADD`).
670///
671/// Returns the clause plus the index of the first token after it.
672fn parse_one_add_column<'a>(
673    stmt: &'a str,
674    toks: &[Tok<'a>],
675    mut i: usize,
676) -> Option<(AddColumnClause, usize)> {
677    if kw_at(toks, i, "COLUMN") {
678        i += 1;
679    }
680
681    let mut if_not_exists = false;
682    if kw_at(toks, i, "IF") && kw_at(toks, i + 1, "NOT") && kw_at(toks, i + 2, "EXISTS") {
683        if_not_exists = true;
684        i += 3;
685    }
686
687    let col = toks.get(i)?;
688    if !is_identifier(col) {
689        return None;
690    }
691    // `ADD CONSTRAINT ...`, `ADD PRIMARY KEY ...` etc. are not columns.
692    if col.kind == TokKind::Word
693        && TABLE_CONSTRAINT_KEYWORDS
694            .iter()
695            .any(|k| col.text.eq_ignore_ascii_case(k))
696    {
697        return None;
698    }
699    let column = col.text.to_string();
700    let column_offset = col.start;
701    i += 1;
702
703    // Data type: everything up to the first top-level constraint keyword,
704    // clause-terminating comma, or end of statement.
705    let type_first = i;
706    let mut depth = 0usize;
707    while i < toks.len() {
708        let t = &toks[i];
709        if t.kind == TokKind::Punct {
710            match t.text {
711                "(" => depth += 1,
712                ")" => {
713                    if depth == 0 {
714                        break;
715                    }
716                    depth -= 1;
717                }
718                "," if depth == 0 => break,
719                _ => {}
720            }
721            i += 1;
722            continue;
723        }
724        if depth == 0
725            && t.kind == TokKind::Word
726            && COLUMN_CONSTRAINT_KEYWORDS
727                .iter()
728                .any(|k| t.text.eq_ignore_ascii_case(k))
729        {
730            break;
731        }
732        i += 1;
733    }
734    let data_type = if i > type_first {
735        normalize_whitespace(&stmt[toks[type_first].start..toks[i - 1].end])
736    } else {
737        "unknown".to_string()
738    };
739
740    // Constraint list: only top-level tokens count.
741    let mut is_not_null = false;
742    let mut default_expr = None;
743    let mut depth = 0usize;
744    while i < toks.len() {
745        let t = &toks[i];
746        if t.kind == TokKind::Punct {
747            match t.text {
748                "(" => depth += 1,
749                ")" => {
750                    if depth == 0 {
751                        break;
752                    }
753                    depth -= 1;
754                }
755                "," if depth == 0 => {
756                    i += 1;
757                    break;
758                }
759                _ => {}
760            }
761            i += 1;
762            continue;
763        }
764        if depth == 0 && t.kind == TokKind::Word {
765            if is_kw(t, "NOT") && kw_at(toks, i + 1, "NULL") {
766                is_not_null = true;
767                i += 2;
768                continue;
769            }
770            if is_kw(t, "DEFAULT") {
771                let (expr, next) = read_default_expr(stmt, toks, i + 1);
772                default_expr = Some(expr);
773                i = next;
774                continue;
775            }
776        }
777        i += 1;
778    }
779
780    Some((
781        AddColumnClause {
782            column,
783            column_offset,
784            data_type,
785            if_not_exists,
786            is_not_null,
787            has_default: default_expr.is_some(),
788            default_expr,
789        },
790        i,
791    ))
792}
793
794/// Read the expression following a top-level `DEFAULT`, stopping at the next
795/// constraint keyword or the end of the column definition.
796///
797/// Returns the expression text and the index of the token after it. The first
798/// token is always consumed so that `DEFAULT NULL` keeps its value.
799fn read_default_expr(stmt: &str, toks: &[Tok<'_>], start: usize) -> (String, usize) {
800    let mut i = start;
801    let mut depth = 0usize;
802    while i < toks.len() {
803        let t = &toks[i];
804        if t.kind == TokKind::Punct {
805            match t.text {
806                "(" => depth += 1,
807                ")" => {
808                    if depth == 0 {
809                        break;
810                    }
811                    depth -= 1;
812                }
813                "," if depth == 0 => break,
814                _ => {}
815            }
816            i += 1;
817            continue;
818        }
819        if depth == 0
820            && i > start
821            && t.kind == TokKind::Word
822            && COLUMN_CONSTRAINT_KEYWORDS
823                .iter()
824                .any(|k| t.text.eq_ignore_ascii_case(k))
825        {
826            break;
827        }
828        i += 1;
829    }
830
831    let expr = if i > start {
832        normalize_whitespace(&stmt[toks[start].start..toks[i - 1].end])
833    } else {
834        String::new()
835    };
836    (expr, i)
837}
838
839/// Collapse runs of whitespace (including newlines) into single spaces.
840fn normalize_whitespace(s: &str) -> String {
841    s.split_whitespace().collect::<Vec<_>>().join(" ")
842}
843
844// ---------------------------------------------------------------------------
845// Tokenizer
846// ---------------------------------------------------------------------------
847
848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
849enum TokKind {
850    /// A bare word: keyword, unquoted identifier, or number.
851    Word,
852    /// A quoted identifier (`"col"` or `` `col` ``); `text` excludes the quotes.
853    Ident,
854    /// A string literal; `text` includes the quotes.
855    Literal,
856    /// A single punctuation character.
857    Punct,
858}
859
860#[derive(Debug, Clone, Copy)]
861struct Tok<'a> {
862    kind: TokKind,
863    text: &'a str,
864    start: usize,
865    end: usize,
866}
867
868fn is_kw(tok: &Tok<'_>, kw: &str) -> bool {
869    tok.kind == TokKind::Word && tok.text.eq_ignore_ascii_case(kw)
870}
871
872fn kw_at(toks: &[Tok<'_>], i: usize, kw: &str) -> bool {
873    toks.get(i).is_some_and(|t| is_kw(t, kw))
874}
875
876/// Whether a token can stand in for an identifier (bare word or quoted).
877fn is_identifier(tok: &Tok<'_>) -> bool {
878    match tok.kind {
879        TokKind::Ident => true,
880        TokKind::Word => tok
881            .text
882            .starts_with(|c: char| c.is_alphabetic() || c == '_'),
883        _ => false,
884    }
885}
886
887/// Split SQL into tokens. Comments and whitespace are skipped.
888fn tokenize(sql: &str) -> Vec<Tok<'_>> {
889    let bytes = sql.as_bytes();
890    let len = bytes.len();
891    let mut toks = Vec::new();
892    let mut i = 0;
893
894    while i < len {
895        let c = bytes[i];
896        if c.is_ascii_whitespace() {
897            i += 1;
898            continue;
899        }
900        if let Some(j) = skip_comment(bytes, i) {
901            i = j;
902            continue;
903        }
904        if c == b'\'' {
905            let j = skip_quoted(sql, i).unwrap_or(len);
906            toks.push(Tok {
907                kind: TokKind::Literal,
908                text: &sql[i..j],
909                start: i,
910                end: j,
911            });
912            i = j;
913            continue;
914        }
915        if c == b'"' || c == b'`' {
916            let mut j = i + 1;
917            while j < len {
918                if bytes[j] == c {
919                    if j + 1 < len && bytes[j + 1] == c {
920                        j += 2;
921                        continue;
922                    }
923                    break;
924                }
925                j += 1;
926            }
927            let inner_end = j.min(len);
928            let end = (j + 1).min(len);
929            toks.push(Tok {
930                kind: TokKind::Ident,
931                text: &sql[i + 1..inner_end],
932                start: i,
933                end,
934            });
935            i = end;
936            continue;
937        }
938        if c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 {
939            let mut j = i;
940            while j < len
941                && (bytes[j].is_ascii_alphanumeric()
942                    || bytes[j] == b'_'
943                    || bytes[j] == b'$'
944                    || bytes[j] >= 0x80)
945            {
946                j += 1;
947            }
948            toks.push(Tok {
949                kind: TokKind::Word,
950                text: &sql[i..j],
951                start: i,
952                end: j,
953            });
954            i = j;
955            continue;
956        }
957        toks.push(Tok {
958            kind: TokKind::Punct,
959            text: &sql[i..i + 1],
960            start: i,
961            end: i + 1,
962        });
963        i += 1;
964    }
965
966    toks
967}
968
969/// Split SQL into individual statements, respecting dollar-quoted blocks,
970/// string literals, quoted identifiers, and comments.
971pub fn split_statements(sql: &str) -> Vec<&str> {
972    statement_ranges(sql)
973        .into_iter()
974        .map(|(s, e)| &sql[s..e])
975        .collect()
976}
977
978/// Byte ranges of the individual statements in `sql`, each trimmed of
979/// surrounding whitespace. Empty statements are skipped.
980fn statement_ranges(sql: &str) -> Vec<(usize, usize)> {
981    let bytes = sql.as_bytes();
982    let len = bytes.len();
983    let mut ranges = Vec::new();
984    let mut start = 0;
985    let mut i = 0;
986
987    while i < len {
988        if let Some(j) = skip_comment(bytes, i) {
989            i = j;
990            continue;
991        }
992        if let Some(j) = skip_quoted(sql, i) {
993            i = j;
994            continue;
995        }
996        if bytes[i] == b';' {
997            if let Some(r) = trim_range(sql, start, i) {
998                ranges.push(r);
999            }
1000            i += 1;
1001            start = i;
1002            continue;
1003        }
1004        i += 1;
1005    }
1006
1007    // Remainder after the last semicolon
1008    if let Some(r) = trim_range(sql, start, len) {
1009        ranges.push(r);
1010    }
1011
1012    ranges
1013}
1014
1015/// Narrow `start..end` to the non-whitespace content it contains, or `None`
1016/// if it is entirely whitespace.
1017fn trim_range(sql: &str, start: usize, end: usize) -> Option<(usize, usize)> {
1018    let slice = &sql[start..end];
1019    if slice.trim().is_empty() {
1020        return None;
1021    }
1022    let lead = slice.len() - slice.trim_start().len();
1023    let trail = slice.len() - slice.trim_end().len();
1024    Some((start + lead, end - trail))
1025}
1026
1027/// If a comment starts at `i`, return the offset just past it.
1028///
1029/// Handles `-- line` comments (terminating before the newline) and nested
1030/// `/* block */` comments.
1031fn skip_comment(bytes: &[u8], i: usize) -> Option<usize> {
1032    let len = bytes.len();
1033    if bytes[i] == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1034        let mut j = i + 2;
1035        while j < len && bytes[j] != b'\n' {
1036            j += 1;
1037        }
1038        return Some(j);
1039    }
1040    if bytes[i] == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1041        let mut j = i + 2;
1042        let mut depth = 1usize;
1043        while j < len && depth > 0 {
1044            if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
1045                depth += 1;
1046                j += 2;
1047            } else if j + 1 < len && bytes[j] == b'*' && bytes[j + 1] == b'/' {
1048                depth -= 1;
1049                j += 2;
1050            } else {
1051                j += 1;
1052            }
1053        }
1054        return Some(j.min(len));
1055    }
1056    None
1057}
1058
1059/// If a quoted region starts at `i`, return the offset just past it.
1060///
1061/// Covers string literals (including `E'...'` escape strings and doubled-quote
1062/// escapes), double-quoted / backtick-quoted identifiers, and dollar-quoted
1063/// blocks.
1064fn skip_quoted(sql: &str, i: usize) -> Option<usize> {
1065    let bytes = sql.as_bytes();
1066    let len = bytes.len();
1067    match bytes[i] {
1068        b'\'' => {
1069            // E'...' escape strings honour backslash escapes.
1070            let is_escape_string = i > 0
1071                && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e')
1072                && (i < 2 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_'));
1073            let mut j = i + 1;
1074            while j < len {
1075                if is_escape_string && bytes[j] == b'\\' {
1076                    j += 2;
1077                    continue;
1078                }
1079                if bytes[j] == b'\'' {
1080                    if j + 1 < len && bytes[j + 1] == b'\'' {
1081                        j += 2; // doubled-quote escape
1082                    } else {
1083                        j += 1;
1084                        break;
1085                    }
1086                } else {
1087                    j += 1;
1088                }
1089            }
1090            Some(j.min(len))
1091        }
1092        q @ (b'"' | b'`') => {
1093            let mut j = i + 1;
1094            while j < len {
1095                if bytes[j] == q {
1096                    if j + 1 < len && bytes[j + 1] == q {
1097                        j += 2; // doubled-quote escape
1098                        continue;
1099                    }
1100                    j += 1;
1101                    break;
1102                }
1103                j += 1;
1104            }
1105            Some(j.min(len))
1106        }
1107        // Dollar-quoted string ($$...$$, $tag$...$tag$)
1108        b'$' => {
1109            let tag_start = i;
1110            let mut j = i + 1;
1111            while j < len && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
1112                j += 1;
1113            }
1114            if j < len && bytes[j] == b'$' {
1115                let tag = &sql[tag_start..=j];
1116                j += 1;
1117                while j < len {
1118                    if bytes[j] == b'$' && sql[j..].starts_with(tag) {
1119                        j += tag.len();
1120                        break;
1121                    }
1122                    j += 1;
1123                }
1124            }
1125            Some(j.min(len))
1126        }
1127        _ => None,
1128    }
1129}
1130
1131/// Blank out every comment in `sql`, preserving byte offsets and line breaks.
1132///
1133/// Comment bytes become spaces (newlines are kept) so the result has exactly
1134/// the same length and line structure as the input. This lets semantic
1135/// analysis run on comment-free SQL while diagnostics still resolve to the
1136/// original source position.
1137pub fn strip_comments(sql: &str) -> String {
1138    let bytes = sql.as_bytes();
1139    let len = bytes.len();
1140    let mut out = bytes.to_vec();
1141    let mut i = 0;
1142
1143    while i < len {
1144        if let Some(j) = skip_comment(bytes, i) {
1145            for b in &mut out[i..j] {
1146                if *b != b'\n' {
1147                    *b = b' ';
1148                }
1149            }
1150            i = j;
1151            continue;
1152        }
1153        if let Some(j) = skip_quoted(sql, i) {
1154            i = j;
1155            continue;
1156        }
1157        i += 1;
1158    }
1159
1160    // Only ASCII comment bytes were replaced with ASCII spaces, so the result
1161    // is still valid UTF-8.
1162    String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
1163}
1164
1165/// The 1-based line number containing the given byte offset.
1166pub fn line_number_at(sql: &str, offset: usize) -> usize {
1167    sql[..offset.min(sql.len())]
1168        .bytes()
1169        .filter(|b| *b == b'\n')
1170        .count()
1171        + 1
1172}
1173
1174/// Split MySQL SQL into individual statements at top-level `;` terminators.
1175///
1176/// Respects single-quoted strings, double-quoted strings, backtick-quoted
1177/// identifiers, single-line `--` comments, and `/* ... */` block comments.
1178/// Does **not** handle MySQL's `DELIMITER //` blocks — stored-procedure DDL
1179/// that needs an alternate delimiter must be split by the caller (or
1180/// re-written without DELIMITER, which works for most ALTER/CREATE patterns).
1181///
1182/// Returns owned `String`s rather than borrowed slices so callers can pass
1183/// them directly to `mysql_async::query_drop` without lifetime gymnastics.
1184pub fn split_mysql_statements(sql: &str) -> Vec<String> {
1185    let bytes = sql.as_bytes();
1186    let len = bytes.len();
1187    let mut out = Vec::new();
1188    let mut start = 0;
1189    let mut i = 0;
1190    while i < len {
1191        let c = bytes[i];
1192        // Line comment
1193        if c == b'-' && i + 1 < len && bytes[i + 1] == b'-' {
1194            while i < len && bytes[i] != b'\n' {
1195                i += 1;
1196            }
1197            continue;
1198        }
1199        // Block comment
1200        if c == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
1201            i += 2;
1202            while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1203                i += 1;
1204            }
1205            i = (i + 2).min(len);
1206            continue;
1207        }
1208        // Single-quoted string
1209        if c == b'\'' {
1210            i += 1;
1211            while i < len && bytes[i] != b'\'' {
1212                if bytes[i] == b'\\' && i + 1 < len {
1213                    i += 2;
1214                } else {
1215                    i += 1;
1216                }
1217            }
1218            i += 1;
1219            continue;
1220        }
1221        // Double-quoted string
1222        if c == b'"' {
1223            i += 1;
1224            while i < len && bytes[i] != b'"' {
1225                if bytes[i] == b'\\' && i + 1 < len {
1226                    i += 2;
1227                } else {
1228                    i += 1;
1229                }
1230            }
1231            i += 1;
1232            continue;
1233        }
1234        // Backtick-quoted identifier
1235        if c == b'`' {
1236            i += 1;
1237            while i < len && bytes[i] != b'`' {
1238                i += 1;
1239            }
1240            i += 1;
1241            continue;
1242        }
1243        // Statement terminator
1244        if c == b';' {
1245            out.push(sql[start..i].to_string());
1246            i += 1;
1247            start = i;
1248            continue;
1249        }
1250        i += 1;
1251    }
1252    let tail = sql[start..].trim();
1253    if !tail.is_empty() {
1254        out.push(tail.to_string());
1255    }
1256    out
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262
1263    #[test]
1264    fn test_split_simple_statements() {
1265        let sql = "SELECT 1; SELECT 2;";
1266        let stmts = split_statements(sql);
1267        assert_eq!(stmts, vec!["SELECT 1", "SELECT 2"]);
1268    }
1269
1270    #[test]
1271    fn test_split_respects_string_literals() {
1272        let sql = "SELECT 'hello;world'; SELECT 2;";
1273        let stmts = split_statements(sql);
1274        assert_eq!(stmts, vec!["SELECT 'hello;world'", "SELECT 2"]);
1275    }
1276
1277    #[test]
1278    fn test_split_respects_dollar_quoting() {
1279        let sql =
1280            "CREATE FUNCTION foo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql; SELECT 1;";
1281        let stmts = split_statements(sql);
1282        assert_eq!(stmts.len(), 2);
1283        assert!(stmts[0].contains("BEGIN; END;"));
1284    }
1285
1286    #[test]
1287    fn test_split_respects_tagged_dollar_quoting() {
1288        let sql = "CREATE FUNCTION foo() RETURNS void AS $body$ BEGIN; END; $body$ LANGUAGE plpgsql; SELECT 1;";
1289        let stmts = split_statements(sql);
1290        assert_eq!(stmts.len(), 2);
1291        assert!(stmts[0].contains("BEGIN; END;"));
1292    }
1293
1294    #[test]
1295    fn test_split_respects_comments() {
1296        let sql = "-- This is a comment with ; semicolon\nSELECT 1;";
1297        let stmts = split_statements(sql);
1298        assert_eq!(stmts.len(), 1);
1299    }
1300
1301    #[test]
1302    fn test_split_no_trailing_semicolon() {
1303        let sql = "SELECT 1";
1304        let stmts = split_statements(sql);
1305        assert_eq!(stmts, vec!["SELECT 1"]);
1306    }
1307
1308    #[test]
1309    fn test_extract_create_table() {
1310        let sql = "CREATE TABLE users (id SERIAL PRIMARY KEY);";
1311        let ops = extract_ddl_operations(sql);
1312        assert_eq!(ops.len(), 1);
1313        match &ops[0] {
1314            DdlOperation::CreateTable {
1315                table,
1316                if_not_exists,
1317            } => {
1318                assert_eq!(table, "users");
1319                assert!(!if_not_exists);
1320            }
1321            _ => panic!("Expected CreateTable"),
1322        }
1323    }
1324
1325    #[test]
1326    fn test_extract_create_table_if_not_exists() {
1327        let sql = "CREATE TABLE IF NOT EXISTS users (id SERIAL);";
1328        let ops = extract_ddl_operations(sql);
1329        match &ops[0] {
1330            DdlOperation::CreateTable {
1331                table,
1332                if_not_exists,
1333            } => {
1334                assert_eq!(table, "users");
1335                assert!(if_not_exists);
1336            }
1337            _ => panic!("Expected CreateTable"),
1338        }
1339    }
1340
1341    #[test]
1342    fn test_extract_add_column() {
1343        let sql = "ALTER TABLE users ADD COLUMN email VARCHAR(255) NOT NULL DEFAULT '';";
1344        let ops = extract_ddl_operations(sql);
1345        match &ops[0] {
1346            DdlOperation::AlterTableAddColumn {
1347                table,
1348                column,
1349                is_not_null,
1350                has_default,
1351                ..
1352            } => {
1353                assert_eq!(table, "users");
1354                assert_eq!(column, "email");
1355                assert!(is_not_null);
1356                assert!(has_default);
1357            }
1358            _ => panic!("Expected AlterTableAddColumn"),
1359        }
1360    }
1361
1362    /// Convenience: the single AddColumn op parsed out of `sql`.
1363    fn add_column(sql: &str) -> DdlOperation {
1364        let ops = extract_ddl_operations(sql);
1365        assert_eq!(ops.len(), 1, "expected exactly one op, got {:?}", ops);
1366        ops.into_iter().next().unwrap()
1367    }
1368
1369    #[test]
1370    fn test_add_column_if_not_exists_names_the_column() {
1371        match add_column(
1372            "ALTER TABLE dicom.reid_shares ADD COLUMN IF NOT EXISTS threshold smallint;",
1373        ) {
1374            DdlOperation::AlterTableAddColumn {
1375                table,
1376                column,
1377                data_type,
1378                is_not_null,
1379                has_default,
1380                if_not_exists,
1381                default_expr,
1382            } => {
1383                assert_eq!(table, "reid_shares");
1384                assert_eq!(column, "threshold");
1385                assert_eq!(data_type, "smallint");
1386                assert!(default_expr.is_none());
1387                assert!(!is_not_null);
1388                assert!(!has_default);
1389                assert!(if_not_exists);
1390            }
1391            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1392        }
1393    }
1394
1395    #[test]
1396    fn test_add_column_ignores_not_null_in_comments() {
1397        let sql = "-- Every ceremony writes the threshold NOT NULL.\n\
1398                   ALTER TABLE dicom.reid_shares\n  \
1399                     ADD COLUMN IF NOT EXISTS threshold smallint\n    \
1400                       CHECK (threshold IS NULL OR threshold BETWEEN 1 AND 255);";
1401        match add_column(sql) {
1402            DdlOperation::AlterTableAddColumn {
1403                column,
1404                is_not_null,
1405                ..
1406            } => {
1407                assert_eq!(column, "threshold");
1408                assert!(!is_not_null, "NOT NULL came from a comment");
1409            }
1410            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1411        }
1412    }
1413
1414    #[test]
1415    fn test_add_column_ignores_not_null_inside_check() {
1416        match add_column("ALTER TABLE t ADD COLUMN c text CHECK (c IS NOT NULL);") {
1417            DdlOperation::AlterTableAddColumn { is_not_null, .. } => assert!(!is_not_null),
1418            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1419        }
1420    }
1421
1422    #[test]
1423    fn test_add_column_ignores_keywords_inside_string_literals() {
1424        match add_column("ALTER TABLE t ADD COLUMN c text DEFAULT 'NOT NULL';") {
1425            DdlOperation::AlterTableAddColumn {
1426                is_not_null,
1427                has_default,
1428                ..
1429            } => {
1430                assert!(!is_not_null);
1431                assert!(has_default);
1432            }
1433            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1434        }
1435    }
1436
1437    #[test]
1438    fn test_add_column_quoted_and_parenthesised_type() {
1439        match add_column(
1440            r#"ALTER TABLE "my schema"."my table" ADD "my col" numeric(10,2) NOT NULL;"#,
1441        ) {
1442            DdlOperation::AlterTableAddColumn {
1443                table,
1444                column,
1445                data_type,
1446                is_not_null,
1447                ..
1448            } => {
1449                assert_eq!(table, "my table");
1450                assert_eq!(column, "my col");
1451                assert_eq!(data_type, "numeric(10,2)");
1452                assert!(is_not_null);
1453            }
1454            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1455        }
1456    }
1457
1458    #[test]
1459    fn test_add_column_multiword_type() {
1460        match add_column("ALTER TABLE t ADD COLUMN c timestamp with time zone NOT NULL;") {
1461            DdlOperation::AlterTableAddColumn {
1462                data_type,
1463                is_not_null,
1464                ..
1465            } => {
1466                assert_eq!(data_type, "timestamp with time zone");
1467                assert!(is_not_null);
1468            }
1469            other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1470        }
1471    }
1472
1473    #[test]
1474    fn test_add_multiple_columns_in_one_statement() {
1475        let ops = extract_ddl_operations(
1476            "ALTER TABLE t ADD COLUMN a int, ADD COLUMN IF NOT EXISTS b text NOT NULL;",
1477        );
1478        assert_eq!(ops.len(), 2);
1479        match (&ops[0], &ops[1]) {
1480            (
1481                DdlOperation::AlterTableAddColumn {
1482                    column: c1,
1483                    is_not_null: n1,
1484                    ..
1485                },
1486                DdlOperation::AlterTableAddColumn {
1487                    column: c2,
1488                    is_not_null: n2,
1489                    if_not_exists,
1490                    ..
1491                },
1492            ) => {
1493                assert_eq!(c1, "a");
1494                assert!(!n1);
1495                assert_eq!(c2, "b");
1496                assert!(n2);
1497                assert!(if_not_exists);
1498            }
1499            other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1500        }
1501    }
1502
1503    #[test]
1504    fn test_add_column_captures_default_expression() {
1505        for (sql, expected) in [
1506            (
1507                "ALTER TABLE t ADD COLUMN c timestamptz DEFAULT now();",
1508                "now()",
1509            ),
1510            ("ALTER TABLE t ADD COLUMN c int NOT NULL DEFAULT 5;", "5"),
1511            (
1512                "ALTER TABLE t ADD COLUMN c text DEFAULT 'x' NOT NULL;",
1513                "'x'",
1514            ),
1515            ("ALTER TABLE t ADD COLUMN c text DEFAULT NULL;", "NULL"),
1516            (
1517                "ALTER TABLE t ADD COLUMN c text[] DEFAULT '{}' CHECK (c IS NOT NULL);",
1518                "'{}'",
1519            ),
1520        ] {
1521            match add_column(sql) {
1522                DdlOperation::AlterTableAddColumn { default_expr, .. } => {
1523                    assert_eq!(default_expr.as_deref(), Some(expected), "for {}", sql)
1524                }
1525                other => panic!("Expected AlterTableAddColumn, got {:?}", other),
1526            }
1527        }
1528    }
1529
1530    #[test]
1531    fn test_add_column_default_is_per_clause() {
1532        let ops = extract_ddl_operations(
1533            "ALTER TABLE t ADD COLUMN a text[] DEFAULT '{}', ADD COLUMN b timestamptz NOT NULL DEFAULT now();",
1534        );
1535        assert_eq!(ops.len(), 2);
1536        match (&ops[0], &ops[1]) {
1537            (
1538                DdlOperation::AlterTableAddColumn {
1539                    default_expr: a, ..
1540                },
1541                DdlOperation::AlterTableAddColumn {
1542                    default_expr: b, ..
1543                },
1544            ) => {
1545                assert_eq!(a.as_deref(), Some("'{}'"));
1546                assert_eq!(b.as_deref(), Some("now()"));
1547            }
1548            other => panic!("Expected two AlterTableAddColumn, got {:?}", other),
1549        }
1550    }
1551
1552    #[test]
1553    fn test_add_constraint_is_not_parsed_as_a_column() {
1554        let ops = extract_ddl_operations("ALTER TABLE t ADD CONSTRAINT t_pk PRIMARY KEY (id);");
1555        assert!(matches!(ops[0], DdlOperation::AddConstraint { .. }));
1556    }
1557
1558    #[test]
1559    fn test_located_ops_point_at_the_column() {
1560        let sql =
1561            "-- comment with NOT NULL\n-- another\nALTER TABLE t\n  ADD COLUMN c int NOT NULL;";
1562        let located = extract_ddl_operations_located(sql);
1563        assert_eq!(located.len(), 1);
1564        // Statement starts on line 3, the column token is on line 4.
1565        assert_eq!(line_number_at(sql, located[0].start), 3);
1566        assert_eq!(line_number_at(sql, located[0].focus), 4);
1567        assert_eq!(&sql[located[0].focus..located[0].focus + 1], "c");
1568    }
1569
1570    #[test]
1571    fn test_strip_comments_preserves_offsets_and_lines() {
1572        let sql = "-- NOT NULL\nSELECT 1; /* NOT NULL */\nSELECT 'not -- a comment';";
1573        let stripped = strip_comments(sql);
1574        assert_eq!(stripped.len(), sql.len());
1575        assert_eq!(stripped.lines().count(), sql.lines().count());
1576        assert!(!stripped.to_uppercase().contains("NOT NULL"));
1577        assert!(stripped.contains("'not -- a comment'"));
1578    }
1579
1580    #[test]
1581    fn test_line_number_at_is_one_based() {
1582        let sql = "a\nb\nc";
1583        assert_eq!(line_number_at(sql, 0), 1);
1584        assert_eq!(line_number_at(sql, 2), 2);
1585        assert_eq!(line_number_at(sql, 4), 3);
1586    }
1587
1588    #[test]
1589    fn test_extract_create_index() {
1590        let sql = "CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users (email);";
1591        let ops = extract_ddl_operations(sql);
1592        match &ops[0] {
1593            DdlOperation::CreateIndex {
1594                name,
1595                table,
1596                is_concurrent,
1597                is_unique,
1598            } => {
1599                assert_eq!(name, "idx_users_email");
1600                assert_eq!(table, "users");
1601                assert!(is_concurrent);
1602                assert!(is_unique);
1603            }
1604            _ => panic!("Expected CreateIndex"),
1605        }
1606    }
1607
1608    #[test]
1609    fn test_extract_create_function() {
1610        let sql = "CREATE OR REPLACE FUNCTION my_func() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;";
1611        let ops = extract_ddl_operations(sql);
1612        match &ops[0] {
1613            DdlOperation::CreateFunction { name } => {
1614                assert_eq!(name, "my_func");
1615            }
1616            _ => panic!("Expected CreateFunction, got {:?}", ops[0]),
1617        }
1618    }
1619
1620    #[test]
1621    fn test_extract_create_enum() {
1622        let sql = "CREATE TYPE mood AS ENUM ('happy', 'sad');";
1623        let ops = extract_ddl_operations(sql);
1624        match &ops[0] {
1625            DdlOperation::CreateEnum { name } => {
1626                assert_eq!(name, "mood");
1627            }
1628            _ => panic!("Expected CreateEnum"),
1629        }
1630    }
1631
1632    #[test]
1633    fn test_extract_multiple() {
1634        let sql = "CREATE TABLE users (id SERIAL); CREATE INDEX idx_users ON users (id); DROP TABLE old_table;";
1635        let ops = extract_ddl_operations(sql);
1636        assert_eq!(ops.len(), 3);
1637    }
1638
1639    #[test]
1640    fn test_extract_truncate() {
1641        let sql = "TRUNCATE TABLE users;";
1642        let ops = extract_ddl_operations(sql);
1643        match &ops[0] {
1644            DdlOperation::TruncateTable { table } => assert_eq!(table, "users"),
1645            _ => panic!("Expected TruncateTable"),
1646        }
1647    }
1648
1649    #[test]
1650    fn test_extract_drop_column() {
1651        let sql = "ALTER TABLE users DROP COLUMN email;";
1652        let ops = extract_ddl_operations(sql);
1653        match &ops[0] {
1654            DdlOperation::AlterTableDropColumn { table, column } => {
1655                assert_eq!(table, "users");
1656                assert_eq!(column, "email");
1657            }
1658            _ => panic!("Expected AlterTableDropColumn"),
1659        }
1660    }
1661
1662    #[test]
1663    fn test_extract_alter_column() {
1664        let sql = "ALTER TABLE users ALTER COLUMN name TYPE text;";
1665        let ops = extract_ddl_operations(sql);
1666        match &ops[0] {
1667            DdlOperation::AlterTableAlterColumn { table, column } => {
1668                assert_eq!(table, "users");
1669                assert_eq!(column, "name");
1670            }
1671            _ => panic!("Expected AlterTableAlterColumn"),
1672        }
1673    }
1674
1675    #[test]
1676    fn test_extract_materialized_view() {
1677        let sql = "CREATE MATERIALIZED VIEW user_stats AS SELECT count(*) FROM users;";
1678        let ops = extract_ddl_operations(sql);
1679        match &ops[0] {
1680            DdlOperation::CreateView {
1681                name,
1682                is_materialized,
1683            } => {
1684                assert_eq!(name, "user_stats");
1685                assert!(is_materialized);
1686            }
1687            _ => panic!("Expected CreateView"),
1688        }
1689    }
1690
1691    #[test]
1692    fn test_block_comment_with_semicolons() {
1693        let sql = "/* comment; with; semicolons */ SELECT 1;";
1694        let stmts = split_statements(sql);
1695        assert_eq!(stmts.len(), 1);
1696    }
1697
1698    #[test]
1699    fn test_escaped_string_quotes() {
1700        let sql = "SELECT 'it''s; here'; SELECT 2;";
1701        let stmts = split_statements(sql);
1702        assert_eq!(stmts.len(), 2);
1703    }
1704
1705    #[test]
1706    fn test_split_respects_e_escape_strings() {
1707        let sql = r"SELECT E'hello\';world'; SELECT 2;";
1708        let stmts = split_statements(sql);
1709        assert_eq!(stmts.len(), 2);
1710        assert!(stmts[0].contains(r"E'hello\';world'"));
1711    }
1712
1713    #[test]
1714    fn test_split_e_string_with_backslash() {
1715        let sql = r"SELECT E'it\'s a test; really'; SELECT 1;";
1716        let stmts = split_statements(sql);
1717        assert_eq!(stmts.len(), 2);
1718    }
1719
1720    #[test]
1721    fn test_split_nested_block_comments() {
1722        let sql = "SELECT /* outer /* inner */ outer */ 1; SELECT 2;";
1723        let stmts = split_statements(sql);
1724        assert_eq!(stmts.len(), 2);
1725        assert_eq!(stmts[1], "SELECT 2");
1726    }
1727
1728    #[test]
1729    fn test_split_whitespace_only() {
1730        let stmts = split_statements("   \n\t  ");
1731        assert!(stmts.is_empty());
1732    }
1733
1734    #[test]
1735    fn test_split_comment_only() {
1736        let stmts = split_statements("-- just a comment\n");
1737        assert_eq!(stmts.len(), 1);
1738        assert_eq!(stmts[0], "-- just a comment");
1739    }
1740
1741    #[test]
1742    fn test_split_mixed_e_and_regular_strings() {
1743        let sql = r"SELECT 'normal;string', E'escape\';string'; SELECT 2;";
1744        let stmts = split_statements(sql);
1745        assert_eq!(stmts.len(), 2);
1746    }
1747
1748    #[test]
1749    fn test_split_mysql_basic() {
1750        let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1751        let stmts = split_mysql_statements(sql);
1752        assert_eq!(stmts.len(), 2);
1753        assert!(stmts[0].contains("CREATE TABLE a"));
1754        assert!(stmts[1].contains("CREATE TABLE b"));
1755    }
1756
1757    #[test]
1758    fn test_split_mysql_respects_backticks_with_semicolons() {
1759        // A backtick-quoted identifier with `;` inside should NOT split.
1760        let sql = "CREATE TABLE `weird;name` (id INT); CREATE TABLE b (id INT);";
1761        let stmts = split_mysql_statements(sql);
1762        assert_eq!(stmts.len(), 2);
1763        assert!(stmts[0].contains("`weird;name`"));
1764    }
1765
1766    #[test]
1767    fn test_split_mysql_respects_string_literals_with_semicolons() {
1768        let sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c;d');";
1769        let stmts = split_mysql_statements(sql);
1770        assert_eq!(stmts.len(), 2);
1771    }
1772
1773    #[test]
1774    fn test_split_mysql_keeps_leading_comments_with_statement() {
1775        // The first chunk contains both the comment header and the CREATE TABLE.
1776        // Splitter doesn't emit comment-only fragments.
1777        let sql = "-- header comment\nCREATE TABLE a (id INT);\nCREATE TABLE b (id INT);";
1778        let stmts = split_mysql_statements(sql);
1779        assert_eq!(stmts.len(), 2);
1780        assert!(stmts[0].contains("CREATE TABLE a"));
1781    }
1782
1783    #[test]
1784    fn test_split_mysql_handles_block_comments() {
1785        let sql = "/* block ; comment */ CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
1786        let stmts = split_mysql_statements(sql);
1787        assert_eq!(stmts.len(), 2);
1788    }
1789
1790    #[test]
1791    fn test_split_mysql_no_trailing_semicolon() {
1792        let sql = "CREATE TABLE a (id INT)";
1793        let stmts = split_mysql_statements(sql);
1794        assert_eq!(stmts.len(), 1);
1795        assert!(stmts[0].contains("CREATE TABLE a"));
1796    }
1797}