Skip to main content

waypoint_core/
sql_parser.rs

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