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