Skip to main content

qail_core/build/
schema.rs

1//! Schema types and parsing for build-time validation.
2
3use crate::ast::Expr;
4use crate::migrate::types::ColumnType;
5use crate::parser::grammar::ddl::parse_column_definition;
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9/// Foreign key relationship definition
10#[derive(Debug, Clone)]
11pub struct ForeignKey {
12    /// Column in this table that references another table
13    pub column: String,
14    /// Name of referenced table
15    pub ref_table: String,
16    /// Column in referenced table
17    pub ref_column: String,
18}
19
20/// Table schema information with column types and relations
21#[derive(Debug, Clone)]
22pub struct TableSchema {
23    /// Table name.
24    pub name: String,
25    /// Column name → Column type (strongly-typed AST enum)
26    pub columns: HashMap<String, ColumnType>,
27    /// Column name → Access Policy (Default: "Public", can be "Protected")
28    pub policies: HashMap<String, String>,
29    /// Foreign key relationships to other tables
30    pub foreign_keys: Vec<ForeignKey>,
31    /// Whether this table has Row-Level Security enabled
32    /// Auto-detected: table has `tenant_id` column or explicit `rls` keyword.
33    pub rls_enabled: bool,
34}
35
36/// Parsed schema from schema.qail file
37#[derive(Debug, Default)]
38pub struct Schema {
39    /// Table schemas keyed by table name.
40    pub tables: HashMap<String, TableSchema>,
41    /// SQL view names (column-level typing is not available in build schema parser).
42    pub views: HashSet<String>,
43    /// Infrastructure resources (bucket, queue, topic)
44    pub resources: HashMap<String, ResourceSchema>,
45}
46
47/// Infrastructure resource schema (bucket, queue, topic)
48#[derive(Debug, Clone)]
49pub struct ResourceSchema {
50    /// Resource name.
51    pub name: String,
52    /// Resource kind (bucket, queue, topic).
53    pub kind: String,
54    /// Cloud provider (e.g. "aws").
55    pub provider: Option<String>,
56    /// Provider-specific properties.
57    pub properties: HashMap<String, String>,
58}
59
60fn strip_schema_comments(line: &str) -> &str {
61    let Some(idx) = schema_comment_start(line, true) else {
62        return line.trim();
63    };
64    line[..idx].trim()
65}
66
67#[cfg(test)]
68fn strip_sql_line_comments(line: &str) -> &str {
69    let Some(idx) = schema_comment_start(line, false) else {
70        return line.trim();
71    };
72    line[..idx].trim()
73}
74
75fn strip_sql_migration_comments(
76    line: &str,
77    in_block_comment: &mut bool,
78    dollar_quote: &mut Option<String>,
79) -> String {
80    let mut out = String::new();
81    let mut in_single = false;
82    let mut in_double = false;
83    let mut suppress_dollar_content = dollar_quote.is_some();
84    let mut i = 0usize;
85
86    while i < line.len() {
87        if *in_block_comment {
88            if line[i..].starts_with("*/") {
89                i += 2;
90                *in_block_comment = false;
91            } else {
92                i += line[i..].chars().next().map(char::len_utf8).unwrap_or(1);
93            }
94            continue;
95        }
96
97        if let Some(delim) = dollar_quote.as_deref() {
98            if line[i..].starts_with(delim) {
99                out.push_str(delim);
100                i += delim.len();
101                *dollar_quote = None;
102                suppress_dollar_content = false;
103            } else if let Some(ch) = line[i..].chars().next() {
104                if !suppress_dollar_content {
105                    out.push(ch);
106                }
107                i += ch.len_utf8();
108            }
109            continue;
110        }
111
112        let Some(ch) = line[i..].chars().next() else {
113            break;
114        };
115
116        if in_single {
117            out.push(ch);
118            if ch == '\'' {
119                if line[i + ch.len_utf8()..].starts_with('\'') {
120                    out.push('\'');
121                    i += ch.len_utf8() + 1;
122                } else {
123                    i += ch.len_utf8();
124                    in_single = false;
125                }
126            } else {
127                i += ch.len_utf8();
128            }
129            continue;
130        }
131
132        if in_double {
133            out.push(ch);
134            if ch == '"' {
135                if line[i + ch.len_utf8()..].starts_with('"') {
136                    out.push('"');
137                    i += ch.len_utf8() + 1;
138                } else {
139                    i += ch.len_utf8();
140                    in_double = false;
141                }
142            } else {
143                i += ch.len_utf8();
144            }
145            continue;
146        }
147
148        match ch {
149            '\'' => {
150                in_single = true;
151                out.push(ch);
152                i += ch.len_utf8();
153            }
154            '"' => {
155                in_double = true;
156                out.push(ch);
157                i += ch.len_utf8();
158            }
159            '$' => {
160                let Some(delim) = sql_dollar_quote_delimiter_at(line, i) else {
161                    out.push(ch);
162                    i += ch.len_utf8();
163                    continue;
164                };
165                out.push_str(delim);
166                i += delim.len();
167                *dollar_quote = Some(delim.to_string());
168            }
169            '-' if line[i + ch.len_utf8()..].starts_with('-') => break,
170            '/' if line[i + ch.len_utf8()..].starts_with('*') => {
171                i += ch.len_utf8() + 1;
172                *in_block_comment = true;
173            }
174            _ => {
175                out.push(ch);
176                i += ch.len_utf8();
177            }
178        }
179    }
180
181    out.trim().to_string()
182}
183
184fn schema_comment_start(line: &str, hash_comments: bool) -> Option<usize> {
185    let bytes = line.as_bytes();
186    let mut in_single = false;
187    let mut in_double = false;
188    let mut i = 0usize;
189
190    while i < bytes.len() {
191        match bytes[i] {
192            b'\'' if !in_double => {
193                if in_single && bytes.get(i + 1) == Some(&b'\'') {
194                    i += 2;
195                    continue;
196                }
197                in_single = !in_single;
198            }
199            b'"' if !in_single => {
200                if in_double && bytes.get(i + 1) == Some(&b'"') {
201                    i += 2;
202                    continue;
203                }
204                in_double = !in_double;
205            }
206            b'-' if !in_single && !in_double && bytes.get(i + 1) == Some(&b'-') => {
207                return Some(i);
208            }
209            b'#' if hash_comments && !in_single && !in_double => return Some(i),
210            _ => {}
211        }
212        i += 1;
213    }
214
215    None
216}
217
218fn sql_dollar_quote_delimiter_at(raw: &str, idx: usize) -> Option<&str> {
219    let bytes = raw.as_bytes();
220    if bytes.get(idx) != Some(&b'$') {
221        return None;
222    }
223
224    let mut end = idx + 1;
225    while end < bytes.len() {
226        match bytes[end] {
227            b'$' => return Some(&raw[idx..=end]),
228            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' => end += 1,
229            _ => return None,
230        }
231    }
232
233    None
234}
235
236impl Schema {
237    /// Parse a schema.qail file
238    pub fn parse_file(path: &str) -> Result<Self, String> {
239        let content = crate::schema_source::read_qail_schema_source(path)?;
240        Self::parse(&content)
241    }
242
243    /// Parse schema from string
244    pub fn parse(content: &str) -> Result<Self, String> {
245        let mut schema = Schema::default();
246        let mut current_table: Option<String> = None;
247        let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
248        let mut current_policies: HashMap<String, String> = HashMap::new();
249        let mut current_fks: Vec<ForeignKey> = Vec::new();
250        let mut current_rls_flag = false;
251        let mut enum_types: HashMap<String, Vec<String>> = HashMap::new();
252
253        let mut lines = content.lines().peekable();
254        while let Some(raw_line) = lines.next() {
255            let line = strip_schema_comments(raw_line);
256
257            // Skip comments and empty lines
258            if line.is_empty() {
259                continue;
260            }
261
262            if current_table.is_none() && line.starts_with("enum ") {
263                let (name, values) = parse_build_enum_declaration(line, &mut lines)?;
264                if enum_types.insert(name.clone(), values).is_some() {
265                    return Err(format!("duplicate enum declaration '{}'", name));
266                }
267                continue;
268            }
269
270            // Resource declarations: bucket, queue, topic
271            // Only match at the top level, NOT inside a table block
272            // (a column named 'topic' inside a table would otherwise be
273            //  misidentified as a resource declaration)
274            if current_table.is_none()
275                && (line.starts_with("bucket ")
276                    || line.starts_with("queue ")
277                    || line.starts_with("topic "))
278            {
279                let parts: Vec<&str> = line.splitn(2, ' ').collect();
280                let kind = parts[0].to_string();
281                let rest = parts.get(1).copied().unwrap_or("").trim();
282
283                // Extract name (before {
284                let has_block = line.contains('{');
285                let (name, block_start) = if has_block {
286                    let (name, block) = rest.split_once('{').unwrap_or((rest, ""));
287                    (name.trim().to_string(), Some(block.to_string()))
288                } else {
289                    let mut parts = rest.split_whitespace();
290                    let name = parts.next().unwrap_or("").to_string();
291                    if parts.next().is_some() {
292                        return Err(format!("Trailing content after {} resource name", kind));
293                    }
294                    (name, None)
295                };
296                if name.is_empty() {
297                    return Err(format!("Missing name for {} declaration", kind));
298                }
299                if !is_build_identifier(&name) {
300                    return Err(format!("Invalid {} resource name '{}'", kind, name));
301                }
302                let mut provider = None;
303                let mut properties = HashMap::new();
304
305                if let Some(mut block) = block_start {
306                    let mut block_content = None;
307                    while block_content.is_none() {
308                        block_content = resource_block_content_before_closing(&block)?;
309                        if block_content.is_some() {
310                            break;
311                        }
312                        let Some(next_line) = lines.next() else {
313                            return Err(format!(
314                                "Unclosed {} resource definition for '{}': expected closing '}}'",
315                                kind, name
316                            ));
317                        };
318                        let inner = strip_schema_comments(next_line);
319                        block.push(' ');
320                        block.push_str(inner);
321                    }
322                    let block = block_content.unwrap_or_default();
323                    let tokens = split_resource_tokens(block.trim())?;
324                    let mut tokens = tokens.iter();
325                    let mut seen_keys = HashSet::new();
326                    while let Some(key) = tokens.next() {
327                        if !seen_keys.insert(key) {
328                            return Err(format!(
329                                "Duplicate resource property '{}' in '{}'",
330                                key, name
331                            ));
332                        }
333                        let Some(val) = tokens.next() else {
334                            return Err(format!(
335                                "Resource property '{}' in '{}' requires a value",
336                                key, name
337                            ));
338                        };
339                        if key == "provider" {
340                            provider = Some(val.to_string());
341                        } else {
342                            properties.insert(key.to_string(), val.to_string());
343                        }
344                    }
345                }
346
347                if schema.resources.contains_key(&name) {
348                    return Err(format!("duplicate resource declaration '{}'", name));
349                }
350                schema.resources.insert(
351                    name.clone(),
352                    ResourceSchema {
353                        name,
354                        kind,
355                        provider,
356                        properties,
357                    },
358                );
359                continue;
360            }
361
362            // View declarations: `view name $$` or `materialized view name $$`
363            // Track view names so query-table validation accepts view-backed reads.
364            if current_table.is_none()
365                && let Some(view_name) = extract_view_name(line)
366            {
367                if !is_build_table_ref(view_name) {
368                    return Err(format!("Invalid view name '{}'", view_name));
369                }
370                if !schema.views.insert(view_name.to_string()) {
371                    return Err(format!("duplicate view declaration '{}'", view_name));
372                }
373                continue;
374            }
375
376            // Table definition: table name { [rls]
377            if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
378                if let Some(table_name) = current_table.as_deref() {
379                    return Err(format!(
380                        "Table declaration encountered before closing table '{}'",
381                        table_name
382                    ));
383                }
384
385                // Parse new table name, check for `rls` keyword
386                // Format: "table bookings rls {" or "table bookings {"
387                let after_table = line.trim_start_matches("table ");
388                let (before_brace, after_brace) = after_table
389                    .split_once('{')
390                    .ok_or_else(|| format!("Invalid table definition: {}", line))?;
391                if !after_brace.trim().is_empty() {
392                    return Err(format!(
393                        "Trailing content after table opening brace for '{}'",
394                        before_brace
395                            .split_whitespace()
396                            .next()
397                            .unwrap_or("<missing>")
398                    ));
399                }
400                let before_brace = before_brace.trim();
401                let parts: Vec<&str> = before_brace.split_whitespace().collect();
402                let Some(name) = parts.first().filter(|name| !name.is_empty()) else {
403                    return Err("Missing name for table declaration".to_string());
404                };
405                if !is_build_table_ref(name) {
406                    return Err(format!("Invalid table name '{}'", name));
407                }
408                let mut seen_rls_option = false;
409                for option in parts.iter().skip(1) {
410                    if *option != "rls" {
411                        return Err(format!("Unknown table option '{}' for '{}'", option, name));
412                    }
413                    if seen_rls_option {
414                        return Err(format!("Duplicate table option 'rls' for '{}'", name));
415                    }
416                    seen_rls_option = true;
417                }
418                current_rls_flag = parts.contains(&"rls");
419                current_table = Some((*name).to_string());
420            }
421            // End of table definition
422            else if let Some(after_brace) = line.strip_prefix('}') {
423                let Some(table_name) = current_table.take() else {
424                    return Err("Unexpected table closing brace".to_string());
425                };
426                if !after_brace.trim().is_empty() {
427                    return Err(format!(
428                        "Trailing content after table closing brace for '{}'",
429                        table_name
430                    ));
431                }
432                if schema.tables.contains_key(&table_name) {
433                    return Err(format!("duplicate table declaration '{}'", table_name));
434                }
435                let has_rls = current_rls_flag || current_columns.contains_key("tenant_id");
436                schema.tables.insert(
437                    table_name.clone(),
438                    TableSchema {
439                        name: table_name,
440                        columns: std::mem::take(&mut current_columns),
441                        policies: std::mem::take(&mut current_policies),
442                        foreign_keys: std::mem::take(&mut current_fks),
443                        rls_enabled: has_rls,
444                    },
445                );
446                current_rls_flag = false;
447            }
448            // Column definition: column_name TYPE [constraints] [ref:table.column] [protected]
449            // Format from qail pull: "flow_name VARCHAR not_null"
450            // New format with FK: "user_id UUID ref:users.id"
451            // New format with Policy: "password_hash TEXT protected"
452            else if current_table.is_some() {
453                let parts: Vec<&str> = line.split_whitespace().collect();
454                if let Some(col_name) = parts.first() {
455                    if !is_build_identifier(col_name) {
456                        let table_name = current_table.as_deref().unwrap_or("<unknown>");
457                        return Err(format!(
458                            "Invalid column name '{}' in table '{}'",
459                            col_name, table_name
460                        ));
461                    }
462                    if current_columns.contains_key(*col_name) {
463                        let table_name = current_table.as_deref().unwrap_or("<unknown>");
464                        return Err(format!(
465                            "duplicate column '{}' in table '{}'",
466                            col_name, table_name
467                        ));
468                    }
469                    let table_name = current_table.as_deref().unwrap_or("<unknown>");
470                    let Some(col_type_str) = parts.get(1).copied() else {
471                        return Err(format!(
472                            "Missing type for column '{}' in table '{}'",
473                            col_name, table_name
474                        ));
475                    };
476                    let col_type = match col_type_str.parse::<ColumnType>() {
477                        Ok(col_type) => col_type,
478                        Err(_) => {
479                            if let Some(values) = enum_types.get(col_type_str) {
480                                ColumnType::Enum {
481                                    name: col_type_str.to_string(),
482                                    values: values.clone(),
483                                }
484                            } else {
485                                return Err(format!(
486                                    "Unknown column type '{}' for column '{}' in table '{}'",
487                                    col_type_str, col_name, table_name
488                                ));
489                            }
490                        }
491                    };
492                    current_columns.insert(col_name.to_string(), col_type);
493
494                    // Check for policies and foreign keys
495                    let mut policy = "Public".to_string();
496                    let mut seen_protected = false;
497                    let mut seen_column_options = HashSet::new();
498                    let mut nullability_option: Option<&str> = None;
499                    let mut generated_option: Option<&str> = None;
500                    let mut has_foreign_key = false;
501                    let mut seen_fk_actions = HashSet::new();
502
503                    let mut i = 2;
504                    while i < parts.len() {
505                        let part = parts[i];
506                        if part == "protected" {
507                            if seen_protected {
508                                return Err(format!(
509                                    "duplicate protected option for column '{}' in table '{}'",
510                                    col_name, table_name
511                                ));
512                            }
513                            seen_protected = true;
514                            policy = "Protected".to_string();
515                        } else if matches!(
516                            part,
517                            "primary_key"
518                                | "not_null"
519                                | "nullable"
520                                | "unique"
521                                | "generated_identity"
522                                | "generated_by_default_identity"
523                        ) {
524                            if !seen_column_options.insert(part) {
525                                return Err(format!(
526                                    "duplicate column option '{}' for column '{}' in table '{}'",
527                                    part, col_name, table_name
528                                ));
529                            }
530                            if matches!(part, "not_null" | "nullable") {
531                                if let Some(existing) = nullability_option {
532                                    return Err(format!(
533                                        "conflicting nullability options '{}' and '{}' for column '{}' in table '{}'",
534                                        existing, part, col_name, table_name
535                                    ));
536                                }
537                                nullability_option = Some(part);
538                            }
539                            if matches!(
540                                part,
541                                "generated_identity" | "generated_by_default_identity"
542                            ) {
543                                if let Some(existing) = generated_option {
544                                    return Err(format!(
545                                        "conflicting generated options '{}' and '{}' for column '{}' in table '{}'",
546                                        existing, part, col_name, table_name
547                                    ));
548                                }
549                                generated_option = Some(part);
550                            }
551                            // Build-time validation only needs shape, type, policy, and relations.
552                        } else if part == "default" {
553                            if i + 1 >= parts.len() {
554                                return Err(format!(
555                                    "default requires a value for column '{}' in table '{}'",
556                                    col_name, table_name
557                                ));
558                            }
559                            break;
560                        } else if part.starts_with("default=")
561                            || part.starts_with("default:")
562                            || part.starts_with("generated_stored(")
563                            || part.starts_with("check(")
564                        {
565                            break;
566                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
567                            // Parse "table.column" or ">table.column"
568                            let (ref_table, ref_column) =
569                                parse_build_ref_spec(ref_spec, col_name, table_name)?;
570                            push_build_foreign_key(
571                                &mut current_fks,
572                                col_name,
573                                ref_table,
574                                ref_column,
575                                table_name,
576                            )?;
577                            has_foreign_key = true;
578                        } else if part == "references" {
579                            if i + 1 >= parts.len() {
580                                return Err(format!(
581                                    "foreign key reference target is required for column '{}' in table '{}'",
582                                    col_name, table_name
583                                ));
584                            }
585                            i += 1;
586                            let (ref_table, ref_column) =
587                                parse_build_references_target(parts[i], col_name, table_name)?;
588                            push_build_foreign_key(
589                                &mut current_fks,
590                                col_name,
591                                ref_table,
592                                ref_column,
593                                table_name,
594                            )?;
595                            has_foreign_key = true;
596                        } else if let Some(ref_target) = part.strip_prefix("references") {
597                            let (ref_table, ref_column) =
598                                parse_build_references_target(ref_target, col_name, table_name)?;
599                            push_build_foreign_key(
600                                &mut current_fks,
601                                col_name,
602                                ref_table,
603                                ref_column,
604                                table_name,
605                            )?;
606                            has_foreign_key = true;
607                        } else if matches!(part, "on_delete" | "on_update") {
608                            if !has_foreign_key {
609                                return Err(format!(
610                                    "{} requires a preceding foreign key for column '{}' in table '{}'",
611                                    part, col_name, table_name
612                                ));
613                            }
614                            if !seen_fk_actions.insert(part) {
615                                return Err(format!(
616                                    "duplicate {} action for column '{}' in table '{}'",
617                                    part, col_name, table_name
618                                ));
619                            }
620                            if i + 1 >= parts.len() {
621                                return Err(format!(
622                                    "{} requires a foreign key action for column '{}' in table '{}'",
623                                    part, col_name, table_name
624                                ));
625                            }
626                            i += 1;
627                            if !is_build_fk_action(parts[i]) {
628                                return Err(format!(
629                                    "unknown foreign key action '{}' for column '{}' in table '{}'",
630                                    parts[i], col_name, table_name
631                                ));
632                            }
633                        } else if part == "check_name" {
634                            if i + 1 >= parts.len() {
635                                return Err(format!(
636                                    "check_name requires a name for column '{}' in table '{}'",
637                                    col_name, table_name
638                                ));
639                            }
640                            i += 1;
641                        } else {
642                            return Err(format!(
643                                "Unknown column option '{}' for column '{}' in table '{}'",
644                                part, col_name, table_name
645                            ));
646                        }
647                        i += 1;
648                    }
649                    current_policies.insert(col_name.to_string(), policy);
650                }
651            }
652        }
653
654        if let Some(table_name) = current_table.take() {
655            return Err(format!(
656                "Unclosed table definition for '{}': expected closing '}}'",
657                table_name
658            ));
659        }
660
661        Ok(schema)
662    }
663
664    /// Check if table exists
665    pub fn has_table(&self, name: &str) -> bool {
666        self.tables.contains_key(name) || self.views.contains(name)
667    }
668
669    /// Get all table names that have RLS enabled
670    pub fn rls_tables(&self) -> Vec<&str> {
671        self.tables
672            .iter()
673            .filter(|(_, ts)| ts.rls_enabled)
674            .map(|(name, _)| name.as_str())
675            .collect()
676    }
677
678    /// Check if a specific table has RLS enabled
679    pub fn is_rls_table(&self, name: &str) -> bool {
680        self.tables.get(name).is_some_and(|t| t.rls_enabled)
681    }
682
683    /// Get table schema
684    pub fn table(&self, name: &str) -> Option<&TableSchema> {
685        self.tables.get(name)
686    }
687
688    /// Merge pending migrations into the schema
689    /// Scans migration directory for:
690    /// - legacy SQL migrations (`up.sql` / `*.sql`)
691    /// - native QAIL migrations (`up.qail` / `*.qail`)
692    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
693        use std::fs;
694
695        let dir = Path::new(migrations_dir);
696        if !dir.exists() {
697            return Ok(0); // No migrations directory
698        }
699
700        let mut merged_count = 0;
701
702        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
703        let entries =
704            fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
705
706        for entry in entries.flatten() {
707            let path = entry.path();
708
709            // Check for migration file candidates in subdirectory (prefer native QAIL),
710            // or direct file entries.
711            let migration_file = if path.is_dir() {
712                let up_qail = path.join("up.qail");
713                let up_sql = path.join("up.sql");
714                if up_qail.exists() {
715                    up_qail
716                } else if up_sql.exists() {
717                    up_sql
718                } else {
719                    continue;
720                }
721            } else if path.extension().is_some_and(|e| e == "qail" || e == "sql") {
722                path.clone()
723            } else {
724                continue;
725            };
726
727            if migration_file.exists() {
728                let content = fs::read_to_string(&migration_file)
729                    .map_err(|e| format!("Failed to read {}: {}", migration_file.display(), e))?;
730
731                if migration_file.extension().is_some_and(|ext| ext == "qail") {
732                    merged_count += self.parse_qail_migration(&content).map_err(|e| {
733                        format!(
734                            "Failed to parse native migration {}: {}",
735                            migration_file.display(),
736                            e
737                        )
738                    })?;
739                } else {
740                    merged_count += self.parse_sql_migration(&content);
741                }
742            }
743        }
744
745        Ok(merged_count)
746    }
747
748    /// Parse native QAIL migration content and merge tables/columns into build schema.
749    pub(crate) fn parse_qail_migration(&mut self, qail: &str) -> Result<usize, String> {
750        let parsed = Schema::parse(qail)?;
751        let mut changes = 0usize;
752
753        for (table_name, parsed_table) in parsed.tables {
754            if let Some(existing) = self.tables.get_mut(&table_name) {
755                for (col_name, col_type) in parsed_table.columns {
756                    if let Some(existing_type) = existing.columns.get(&col_name) {
757                        if existing_type != &col_type {
758                            return Err(format!(
759                                "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
760                                table_name, col_name, existing_type, col_type
761                            ));
762                        }
763                    } else {
764                        existing.columns.insert(col_name.clone(), col_type);
765                        changes += 1;
766                    }
767                }
768                for (col_name, policy) in parsed_table.policies {
769                    if existing.policies.insert(col_name, policy).is_none() {
770                        changes += 1;
771                    }
772                }
773                for fk in parsed_table.foreign_keys {
774                    let duplicate = existing.foreign_keys.iter().any(|existing_fk| {
775                        existing_fk.column == fk.column
776                            && existing_fk.ref_table == fk.ref_table
777                            && existing_fk.ref_column == fk.ref_column
778                    });
779                    if !duplicate {
780                        existing.foreign_keys.push(fk);
781                        changes += 1;
782                    }
783                }
784                if parsed_table.rls_enabled && !existing.rls_enabled {
785                    existing.rls_enabled = true;
786                    changes += 1;
787                }
788            } else {
789                changes += 1 + parsed_table.columns.len();
790                self.tables.insert(table_name, parsed_table);
791            }
792        }
793
794        for view_name in parsed.views {
795            if self.views.insert(view_name) {
796                changes += 1;
797            }
798        }
799        for (resource_name, resource) in parsed.resources {
800            if self.resources.insert(resource_name, resource).is_none() {
801                changes += 1;
802            }
803        }
804
805        changes += self.parse_explicit_qail_apply_commands(qail)?;
806
807        Ok(changes)
808    }
809
810    fn parse_explicit_qail_apply_commands(&mut self, qail: &str) -> Result<usize, String> {
811        let mut changes = 0usize;
812
813        for (line_no, raw_line) in qail.lines().enumerate() {
814            let line = strip_schema_comments(raw_line);
815            if line.is_empty() || !line.starts_with("alter ") {
816                continue;
817            }
818
819            let (table, column_name, column_type) = parse_explicit_alter_add_column_line(line)
820                .map_err(|err| format!("Line {}: {}", line_no + 1, err))?;
821
822            if let Some(existing) = self.tables.get_mut(&table) {
823                if let Some(existing_type) = existing.columns.get(&column_name) {
824                    if existing_type != &column_type {
825                        return Err(format!(
826                            "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
827                            table, column_name, existing_type, column_type
828                        ));
829                    }
830                } else {
831                    existing.columns.insert(column_name, column_type);
832                    changes += 1;
833                }
834            } else {
835                let mut columns = HashMap::new();
836                columns.insert(column_name, column_type);
837                self.tables.insert(
838                    table.clone(),
839                    TableSchema {
840                        name: table,
841                        columns,
842                        policies: HashMap::new(),
843                        foreign_keys: vec![],
844                        rls_enabled: false,
845                    },
846                );
847                changes += 2;
848            }
849        }
850
851        Ok(changes)
852    }
853
854    /// Parse SQL migration content and extract schema changes
855    pub(crate) fn parse_sql_migration(&mut self, sql: &str) -> usize {
856        let mut changes = 0;
857
858        for statement in sql_migration_statements(sql) {
859            let line = statement.as_str();
860            let line_upper = line.to_uppercase();
861
862            if let Some((name, after_table_name)) = extract_create_table_name_with_tail(line) {
863                let table_existed = self.tables.contains_key(&name);
864                if !table_existed {
865                    self.tables.insert(
866                        name.clone(),
867                        TableSchema {
868                            name: name.clone(),
869                            columns: HashMap::new(),
870                            policies: HashMap::new(),
871                            foreign_keys: vec![],
872                            rls_enabled: false,
873                        },
874                    );
875                    changes += 1;
876                }
877
878                let after_table_name = after_table_name.trim_start();
879                let has_column_block =
880                    after_table_name.is_empty() || after_table_name.starts_with('(');
881                // Only track column extraction for tables that DON'T already
882                // have their types from schema.qail. Tables that existed before
883                // this migration already have correct types; overwriting them
884                // with ColumnType::Text would be a bug.
885                if has_column_block
886                    && (!table_existed
887                        || self.tables.get(&name).is_some_and(|t| t.columns.is_empty()))
888                {
889                    for col in extract_inline_create_columns(line) {
890                        if let Some(t) = self.tables.get_mut(&name)
891                            && t.columns.insert(col, ColumnType::Text).is_none()
892                        {
893                            changes += 1;
894                        }
895                    }
896                }
897                continue;
898            }
899
900            // ALTER TABLE ... ADD [COLUMN] ...
901            for (table, col) in extract_alter_add_columns(line) {
902                if let Some(t) = self.tables.get_mut(&table) {
903                    if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
904                        changes += 1;
905                    }
906                } else {
907                    // Table might be new from this migration
908                    let mut cols = HashMap::new();
909                    cols.insert(col, ColumnType::Text);
910                    self.tables.insert(
911                        table.clone(),
912                        TableSchema {
913                            name: table,
914                            columns: cols,
915                            policies: HashMap::new(),
916                            foreign_keys: vec![],
917                            rls_enabled: false,
918                        },
919                    );
920                    changes += 1;
921                }
922            }
923
924            // DROP TABLE
925            if line_upper.starts_with("DROP TABLE") {
926                for table_name in extract_drop_table_names(line) {
927                    if self.tables.remove(&table_name).is_some() {
928                        changes += 1;
929                    }
930                }
931            }
932
933            // ALTER TABLE ... DROP [COLUMN] ...
934            for (table, col) in extract_alter_drop_columns(line) {
935                if let Some(t) = self.tables.get_mut(&table)
936                    && t.columns.remove(&col).is_some()
937                {
938                    changes += 1;
939                }
940            }
941
942            // ALTER TABLE ... RENAME COLUMN old TO new
943            if line_upper.starts_with("ALTER TABLE")
944                && let Some((table, old_col, new_col)) = extract_alter_rename_column(line)
945                && let Some(t) = self.tables.get_mut(&table)
946            {
947                let old_type = t.columns.remove(&old_col);
948                if old_type.is_some() {
949                    changes += 1;
950                }
951                if t.columns
952                    .insert(new_col, old_type.unwrap_or(ColumnType::Text))
953                    .is_none()
954                {
955                    changes += 1;
956                }
957            }
958
959            // ALTER TABLE ... RENAME TO new_table
960            if line_upper.starts_with("ALTER TABLE")
961                && let Some((old_table, new_table)) = extract_alter_rename_table(line)
962                && !self.tables.contains_key(&new_table)
963                && let Some(mut table) = self.tables.remove(&old_table)
964            {
965                table.name = new_table.clone();
966                self.tables.insert(new_table, table);
967                changes += 1;
968            }
969        }
970
971        changes
972    }
973}
974
975fn sql_migration_statements(sql: &str) -> Vec<String> {
976    let mut cleaned = String::new();
977    let mut in_block_comment = false;
978    let mut dollar_quote = None;
979
980    for raw_line in sql.lines() {
981        let line = strip_sql_migration_comments(raw_line, &mut in_block_comment, &mut dollar_quote);
982        if line.is_empty() {
983            continue;
984        }
985        cleaned.push_str(&line);
986        cleaned.push('\n');
987    }
988
989    split_sql_statements(&cleaned)
990}
991
992fn parse_build_enum_declaration<'a, I: Iterator<Item = &'a str>>(
993    first_line: &str,
994    lines: &mut std::iter::Peekable<I>,
995) -> Result<(String, Vec<String>), String> {
996    let rest = first_line
997        .strip_prefix("enum ")
998        .ok_or_else(|| "Expected 'enum' prefix".to_string())?
999        .trim();
1000    let (name, body_start) = rest
1001        .split_once('{')
1002        .ok_or_else(|| "enum definition requires { values }".to_string())?;
1003    let name = name.trim();
1004    if name.is_empty() {
1005        return Err("enum name is missing before '{'".to_string());
1006    }
1007    if !is_build_table_ref(name) {
1008        return Err(format!("Invalid enum name '{}'", name));
1009    }
1010
1011    let mut body = body_start.to_string();
1012    while build_enum_body_before_closing_brace(&body)?.is_none() {
1013        let Some(next_line) = lines.next() else {
1014            return Err(format!("enum '{}' is missing closing '}}'", name));
1015        };
1016        let inner = strip_schema_comments(next_line);
1017        body.push(' ');
1018        body.push_str(inner);
1019    }
1020
1021    let body = build_enum_body_before_closing_brace(&body)?
1022        .ok_or_else(|| format!("enum '{}' is missing closing '}}'", name))?;
1023    let values = parse_build_enum_values(body)?;
1024    if values.is_empty() {
1025        return Err(format!("enum '{}' must have at least one value", name));
1026    }
1027
1028    Ok((name.to_string(), values))
1029}
1030
1031fn build_enum_body_before_closing_brace(raw: &str) -> Result<Option<&str>, String> {
1032    let mut quote: Option<char> = None;
1033    let mut chars = raw.char_indices().peekable();
1034
1035    while let Some((idx, ch)) = chars.next() {
1036        if let Some(q) = quote {
1037            if ch == q {
1038                if chars.peek().is_some_and(|(_, next)| *next == q) {
1039                    chars.next();
1040                } else {
1041                    quote = None;
1042                }
1043            }
1044            continue;
1045        }
1046
1047        match ch {
1048            '\'' | '"' => quote = Some(ch),
1049            '}' => {
1050                let rest = &raw[idx + ch.len_utf8()..];
1051                if !rest.trim().is_empty() {
1052                    return Err("trailing content after enum block".to_string());
1053                }
1054                return Ok(Some(&raw[..idx]));
1055            }
1056            _ => {}
1057        }
1058    }
1059
1060    Ok(None)
1061}
1062
1063fn parse_build_enum_values(raw: &str) -> Result<Vec<String>, String> {
1064    let mut values = Vec::new();
1065    let mut quote: Option<char> = None;
1066    let mut start = 0;
1067    let mut chars = raw.char_indices().peekable();
1068
1069    while let Some((idx, ch)) = chars.next() {
1070        if let Some(q) = quote {
1071            if ch == q {
1072                if chars.peek().is_some_and(|(_, next)| *next == q) {
1073                    chars.next();
1074                } else {
1075                    quote = None;
1076                }
1077            }
1078            continue;
1079        }
1080
1081        match ch {
1082            '\'' | '"' => quote = Some(ch),
1083            ',' => {
1084                push_build_enum_value(&mut values, &raw[start..idx])?;
1085                start = idx + ch.len_utf8();
1086            }
1087            _ => {}
1088        }
1089    }
1090
1091    if quote.is_some() {
1092        return Err("unterminated quoted enum value".to_string());
1093    }
1094
1095    push_build_enum_value(&mut values, &raw[start..])?;
1096    let mut seen = HashSet::new();
1097    for value in &values {
1098        if !seen.insert(value) {
1099            return Err(format!("duplicate enum value '{}'", value));
1100        }
1101    }
1102
1103    Ok(values)
1104}
1105
1106fn push_build_enum_value(values: &mut Vec<String>, raw: &str) -> Result<(), String> {
1107    let was_quoted = raw
1108        .trim()
1109        .chars()
1110        .next()
1111        .is_some_and(|ch| matches!(ch, '\'' | '"'));
1112    let value = parse_build_enum_value(raw)?;
1113    if value.is_empty() && !was_quoted {
1114        return Err("enum value is empty".to_string());
1115    }
1116    values.push(value);
1117    Ok(())
1118}
1119
1120fn parse_build_enum_value(raw: &str) -> Result<String, String> {
1121    let trimmed = raw.trim();
1122    if trimmed.is_empty() {
1123        return Ok(String::new());
1124    }
1125
1126    if let Some(quote) = trimmed.chars().next().filter(|ch| matches!(ch, '"' | '\'')) {
1127        let mut value = String::new();
1128        let mut chars = trimmed.char_indices();
1129        chars.next();
1130        let mut chars = chars.peekable();
1131
1132        while let Some((idx, ch)) = chars.next() {
1133            if ch == quote {
1134                if chars.peek().is_some_and(|(_, next)| *next == quote) {
1135                    value.push(quote);
1136                    chars.next();
1137                    continue;
1138                }
1139
1140                let after = idx + ch.len_utf8();
1141                if !trimmed[after..].trim().is_empty() {
1142                    return Err(format!("invalid enum value token '{}'", trimmed));
1143                }
1144                return Ok(value);
1145            }
1146
1147            value.push(ch);
1148        }
1149
1150        return Err("unterminated quoted enum value".to_string());
1151    }
1152
1153    if trimmed
1154        .chars()
1155        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1156    {
1157        return Ok(trimmed.to_string());
1158    }
1159
1160    Err(format!("invalid enum value token '{}'", trimmed))
1161}
1162
1163fn parse_build_references_target(
1164    target: &str,
1165    col_name: &str,
1166    table_name: &str,
1167) -> Result<(String, String), String> {
1168    let target = target.trim();
1169    let (ref_table, ref_column) = target.split_once('(').ok_or_else(|| {
1170        format!(
1171            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1172            target, col_name, table_name
1173        )
1174    })?;
1175    let ref_column = ref_column.strip_suffix(')').ok_or_else(|| {
1176        format!(
1177            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1178            target, col_name, table_name
1179        )
1180    })?;
1181    let ref_table = ref_table.trim();
1182    let ref_column = ref_column.trim();
1183    if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1184        return Err(format!(
1185            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1186            target, col_name, table_name
1187        ));
1188    }
1189
1190    Ok((ref_table.to_string(), ref_column.to_string()))
1191}
1192
1193fn parse_build_ref_spec(
1194    ref_spec: &str,
1195    col_name: &str,
1196    table_name: &str,
1197) -> Result<(String, String), String> {
1198    let ref_spec = ref_spec.trim_start_matches('>');
1199    let (ref_table, ref_column) = ref_spec.split_once('.').ok_or_else(|| {
1200        format!(
1201            "Invalid ref target '{}' for column '{}' in table '{}'",
1202            ref_spec, col_name, table_name
1203        )
1204    })?;
1205    let ref_table = ref_table.trim();
1206    let ref_column = ref_column.trim();
1207    if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1208        return Err(format!(
1209            "Invalid ref target '{}' for column '{}' in table '{}'",
1210            ref_spec, col_name, table_name
1211        ));
1212    }
1213
1214    Ok((ref_table.to_string(), ref_column.to_string()))
1215}
1216
1217fn push_build_foreign_key(
1218    foreign_keys: &mut Vec<ForeignKey>,
1219    column: &str,
1220    ref_table: String,
1221    ref_column: String,
1222    table_name: &str,
1223) -> Result<(), String> {
1224    if foreign_keys
1225        .iter()
1226        .any(|fk| fk.column == column && fk.ref_table == ref_table && fk.ref_column == ref_column)
1227    {
1228        return Err(format!(
1229            "duplicate foreign key '{}.{} -> {}.{}'",
1230            table_name, column, ref_table, ref_column
1231        ));
1232    }
1233
1234    foreign_keys.push(ForeignKey {
1235        column: column.to_string(),
1236        ref_table,
1237        ref_column,
1238    });
1239    Ok(())
1240}
1241
1242fn is_build_table_ref(value: &str) -> bool {
1243    let mut parts = value.split('.');
1244    let Some(first) = parts.next() else {
1245        return false;
1246    };
1247    !first.is_empty() && is_build_identifier(first) && parts.all(is_build_identifier)
1248}
1249
1250fn is_build_identifier(value: &str) -> bool {
1251    !value.is_empty()
1252        && value
1253            .chars()
1254            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1255}
1256
1257fn is_build_fk_action(value: &str) -> bool {
1258    matches!(
1259        value,
1260        "cascade" | "set_null" | "set_default" | "restrict" | "no_action"
1261    )
1262}
1263
1264fn resource_block_content_before_closing(content: &str) -> Result<Option<String>, String> {
1265    let mut quote: Option<char> = None;
1266    let mut escaped = false;
1267
1268    for (idx, ch) in content.char_indices() {
1269        if escaped {
1270            escaped = false;
1271            continue;
1272        }
1273
1274        match quote {
1275            Some(q) => match ch {
1276                '\\' => escaped = true,
1277                c if c == q => quote = None,
1278                _ => {}
1279            },
1280            None => match ch {
1281                '"' | '\'' => quote = Some(ch),
1282                '}' => {
1283                    let rest = &content[idx + ch.len_utf8()..];
1284                    if !rest.trim().is_empty() {
1285                        return Err("Trailing content after resource definition".to_string());
1286                    }
1287                    return Ok(Some(content[..idx].trim().to_string()));
1288                }
1289                _ => {}
1290            },
1291        }
1292    }
1293
1294    Ok(None)
1295}
1296
1297fn split_resource_tokens(content: &str) -> Result<Vec<String>, String> {
1298    let mut tokens = Vec::new();
1299    let mut current = String::new();
1300    let mut quote: Option<char> = None;
1301    let mut escaped = false;
1302
1303    for ch in content.chars() {
1304        if escaped {
1305            current.push(ch);
1306            escaped = false;
1307            continue;
1308        }
1309
1310        match quote {
1311            Some(q) => match ch {
1312                '\\' => escaped = true,
1313                c if c == q => quote = None,
1314                c => current.push(c),
1315            },
1316            None => match ch {
1317                '"' | '\'' => quote = Some(ch),
1318                c if c.is_whitespace() => {
1319                    if !current.is_empty() {
1320                        tokens.push(std::mem::take(&mut current));
1321                    }
1322                }
1323                c => current.push(c),
1324            },
1325        }
1326    }
1327
1328    if escaped {
1329        current.push('\\');
1330    }
1331    if quote.is_some() {
1332        return Err("Unterminated quoted resource value".to_string());
1333    }
1334    if !current.is_empty() {
1335        tokens.push(current);
1336    }
1337
1338    Ok(tokens)
1339}
1340
1341fn parse_explicit_alter_add_column_line(
1342    line: &str,
1343) -> Result<(String, String, ColumnType), String> {
1344    let rest = line
1345        .strip_prefix("alter ")
1346        .ok_or_else(|| "expected 'alter <table> add <column:type[:constraints]>'".to_string())?
1347        .trim();
1348
1349    let mut parts = rest.splitn(2, char::is_whitespace);
1350    let table = parts
1351        .next()
1352        .map(str::trim)
1353        .filter(|table| !table.is_empty())
1354        .ok_or_else(|| "expected table name after 'alter'".to_string())?;
1355    if !is_build_table_ref(table) {
1356        return Err(format!("invalid alter table name '{}'", table));
1357    }
1358    let remainder = parts
1359        .next()
1360        .map(str::trim)
1361        .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?;
1362    let column_def = remainder
1363        .strip_prefix("add ")
1364        .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?
1365        .trim();
1366
1367    if column_def.is_empty() {
1368        return Err("expected column definition after 'add'".to_string());
1369    }
1370
1371    let (remaining, column_expr) = parse_column_definition(column_def)
1372        .map_err(|_| format!("invalid column definition '{}'", column_def))?;
1373    if !remaining.trim().is_empty() {
1374        return Err(format!(
1375            "unexpected trailing content after column definition: '{}'",
1376            remaining.trim()
1377        ));
1378    }
1379
1380    match column_expr {
1381        Expr::Def {
1382            name, data_type, ..
1383        } => {
1384            let column_type = data_type.parse::<ColumnType>().map_err(|_| {
1385                format!(
1386                    "unknown column type '{}' for column '{}' in alter '{}'",
1387                    data_type, name, table
1388                )
1389            })?;
1390            Ok((table.to_string(), name, column_type))
1391        }
1392        _ => Err("expected column definition after 'add'".to_string()),
1393    }
1394}
1395
1396fn extract_view_name(line: &str) -> Option<&str> {
1397    let rest = if let Some(r) = line.strip_prefix("view ") {
1398        r
1399    } else {
1400        line.strip_prefix("materialized view ")?
1401    };
1402
1403    let name = rest.split_whitespace().next().unwrap_or_default().trim();
1404    if name.is_empty() { None } else { Some(name) }
1405}
1406
1407fn extract_create_table_name_with_tail(line: &str) -> Option<(String, &str)> {
1408    let rest = extract_create_table_target_start(line)?;
1409    let rest = strip_sql_if_not_exists(rest).unwrap_or(rest);
1410
1411    extract_sql_table_ref_with_tail(rest)
1412}
1413
1414fn extract_create_table_target_start(line: &str) -> Option<&str> {
1415    let mut rest = strip_sql_keyword(line, "CREATE")?;
1416
1417    if let Some(after_unlogged) = strip_sql_keyword(rest, "UNLOGGED") {
1418        rest = after_unlogged;
1419    } else if strip_sql_keyword(rest, "TEMP")
1420        .or_else(|| strip_sql_keyword(rest, "TEMPORARY"))
1421        .is_some()
1422    {
1423        return None;
1424    }
1425
1426    strip_sql_keyword(rest, "TABLE")
1427}
1428
1429fn strip_sql_keyword<'a>(raw: &'a str, keyword: &str) -> Option<&'a str> {
1430    let rest = raw.trim_start();
1431    let tail = rest.get(keyword.len()..)?;
1432    if rest[..keyword.len()].eq_ignore_ascii_case(keyword)
1433        && (tail.is_empty() || tail.starts_with(char::is_whitespace))
1434    {
1435        Some(tail.trim_start())
1436    } else {
1437        None
1438    }
1439}
1440
1441fn strip_sql_if_exists(raw: &str) -> Option<&str> {
1442    let after_if = strip_sql_keyword(raw, "IF")?;
1443    strip_sql_keyword(after_if, "EXISTS")
1444}
1445
1446fn strip_sql_if_not_exists(raw: &str) -> Option<&str> {
1447    let after_if = strip_sql_keyword(raw, "IF")?;
1448    let after_not = strip_sql_keyword(after_if, "NOT")?;
1449    strip_sql_keyword(after_not, "EXISTS")
1450}
1451
1452/// Extract column name from a line inside CREATE TABLE block
1453fn extract_column_from_create(line: &str) -> Option<String> {
1454    let line = line.trim();
1455
1456    // Skip keywords and constraints
1457    // IMPORTANT: Must check for word boundaries to avoid matching column names
1458    // that happen to start with a keyword (e.g., created_at starts with CREATE,
1459    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
1460    let line_upper = line.to_uppercase();
1461    let starts_with_keyword = |kw: &str| -> bool {
1462        line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
1463    };
1464
1465    if starts_with_keyword("CREATE")
1466        || starts_with_keyword("PRIMARY")
1467        || starts_with_keyword("FOREIGN")
1468        || starts_with_keyword("UNIQUE")
1469        || starts_with_keyword("CHECK")
1470        || starts_with_keyword("CONSTRAINT")
1471        || starts_with_keyword("EXCLUDE")
1472        || starts_with_keyword("LIKE")
1473        || line_upper.starts_with(")")
1474        || line_upper.starts_with("(")
1475        || line.is_empty()
1476    {
1477        return None;
1478    }
1479
1480    extract_sql_column_ref(line.trim_start_matches('(').trim())
1481}
1482
1483fn extract_inline_create_columns(line: &str) -> Vec<String> {
1484    let Some(open_idx) = line.find('(') else {
1485        return Vec::new();
1486    };
1487    let Some(close_idx) = find_matching_sql_paren(line, open_idx) else {
1488        return Vec::new();
1489    };
1490    let body = &line[open_idx + 1..close_idx];
1491    split_sql_top_level_csv(body)
1492        .into_iter()
1493        .filter_map(extract_column_from_create)
1494        .collect()
1495}
1496
1497fn find_matching_sql_paren(raw: &str, open_idx: usize) -> Option<usize> {
1498    let mut depth = 0usize;
1499    let mut in_single = false;
1500    let mut in_double = false;
1501    let mut dollar_quote: Option<String> = None;
1502    let mut i = open_idx;
1503
1504    while i < raw.len() {
1505        if let Some(delim) = dollar_quote.as_deref() {
1506            if raw[i..].starts_with(delim) {
1507                i += delim.len();
1508                dollar_quote = None;
1509            } else {
1510                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1511            }
1512            continue;
1513        }
1514
1515        let ch = raw[i..].chars().next()?;
1516        match ch {
1517            '\'' if !in_double => {
1518                if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1519                    i += 2;
1520                    continue;
1521                }
1522                in_single = !in_single;
1523            }
1524            '"' if !in_single => {
1525                if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1526                    i += 2;
1527                    continue;
1528                }
1529                in_double = !in_double;
1530            }
1531            '$' if !in_single && !in_double => {
1532                if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1533                    dollar_quote = Some(delim.to_string());
1534                    i += delim.len();
1535                    continue;
1536                }
1537            }
1538            '(' if !in_single && !in_double => depth += 1,
1539            ')' if !in_single && !in_double => {
1540                depth = depth.checked_sub(1)?;
1541                if depth == 0 {
1542                    return Some(i);
1543                }
1544            }
1545            _ => {}
1546        }
1547        i += ch.len_utf8();
1548    }
1549
1550    None
1551}
1552
1553fn split_sql_top_level_csv(raw: &str) -> Vec<&str> {
1554    let mut pieces = Vec::new();
1555    let mut start = 0usize;
1556    let mut depth = 0usize;
1557    let mut in_single = false;
1558    let mut in_double = false;
1559    let mut dollar_quote: Option<String> = None;
1560    let mut i = 0usize;
1561
1562    while i < raw.len() {
1563        if let Some(delim) = dollar_quote.as_deref() {
1564            if raw[i..].starts_with(delim) {
1565                i += delim.len();
1566                dollar_quote = None;
1567            } else {
1568                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1569            }
1570            continue;
1571        }
1572
1573        let Some(ch) = raw[i..].chars().next() else {
1574            break;
1575        };
1576        match ch {
1577            '\'' if !in_double => {
1578                if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1579                    i += 2;
1580                    continue;
1581                }
1582                in_single = !in_single;
1583            }
1584            '"' if !in_single => {
1585                if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1586                    i += 2;
1587                    continue;
1588                }
1589                in_double = !in_double;
1590            }
1591            '$' if !in_single && !in_double => {
1592                if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1593                    dollar_quote = Some(delim.to_string());
1594                    i += delim.len();
1595                    continue;
1596                }
1597            }
1598            '(' if !in_single && !in_double => depth += 1,
1599            ')' if !in_single && !in_double => depth = depth.saturating_sub(1),
1600            ',' if depth == 0 => {
1601                pieces.push(raw[start..i].trim());
1602                start = i + ch.len_utf8();
1603            }
1604            _ => {}
1605        }
1606        i += ch.len_utf8();
1607    }
1608
1609    pieces.push(raw[start..].trim());
1610    pieces
1611}
1612
1613fn split_sql_statements(raw: &str) -> Vec<String> {
1614    let mut statements = Vec::new();
1615    let mut start = 0usize;
1616    let mut in_single = false;
1617    let mut in_double = false;
1618    let mut dollar_quote: Option<String> = None;
1619    let mut i = 0usize;
1620
1621    while i < raw.len() {
1622        if let Some(delim) = dollar_quote.as_deref() {
1623            if raw[i..].starts_with(delim) {
1624                i += delim.len();
1625                dollar_quote = None;
1626            } else {
1627                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1628            }
1629            continue;
1630        }
1631
1632        let Some(ch) = raw[i..].chars().next() else {
1633            break;
1634        };
1635        match ch {
1636            '\'' if !in_double => {
1637                if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1638                    i += 2;
1639                    continue;
1640                }
1641                in_single = !in_single;
1642            }
1643            '"' if !in_single => {
1644                if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1645                    i += 2;
1646                    continue;
1647                }
1648                in_double = !in_double;
1649            }
1650            '$' if !in_single && !in_double => {
1651                if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1652                    dollar_quote = Some(delim.to_string());
1653                    i += delim.len();
1654                    continue;
1655                }
1656            }
1657            ';' if !in_single && !in_double => {
1658                let statement = raw[start..i].trim();
1659                if !statement.is_empty() {
1660                    statements.push(statement.to_string());
1661                }
1662                start = i + ch.len_utf8();
1663            }
1664            _ => {}
1665        }
1666        i += ch.len_utf8();
1667    }
1668
1669    let tail = raw[start..].trim();
1670    if !tail.is_empty() {
1671        statements.push(tail.to_string());
1672    }
1673
1674    statements
1675}
1676
1677/// Extract table and columns from ALTER TABLE ... ADD [COLUMN] actions.
1678fn extract_alter_add_columns(line: &str) -> Vec<(String, String)> {
1679    let line_upper = line.to_uppercase();
1680    if !line_upper.starts_with("ALTER TABLE") {
1681        return Vec::new();
1682    }
1683    let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1684        return Vec::new();
1685    };
1686
1687    split_sql_top_level_csv(actions_part)
1688        .into_iter()
1689        .filter_map(|action| {
1690            extract_alter_add_column_action(action).map(|col| (table.clone(), col))
1691        })
1692        .collect()
1693}
1694
1695fn extract_alter_add_column_action(action: &str) -> Option<String> {
1696    let mut col_part = strip_sql_keyword(action, "ADD")?;
1697    col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1698    col_part = strip_sql_if_not_exists(col_part).unwrap_or(col_part);
1699
1700    let col_upper = col_part.trim_start().to_uppercase();
1701    if [
1702        "CONSTRAINT",
1703        "PRIMARY",
1704        "UNIQUE",
1705        "CHECK",
1706        "FOREIGN",
1707        "EXCLUDE",
1708    ]
1709    .iter()
1710    .any(|keyword| {
1711        col_upper.starts_with(keyword) && col_upper[keyword.len()..].starts_with([' ', '('])
1712    }) {
1713        return None;
1714    }
1715
1716    extract_sql_column_ref(col_part.trim())
1717}
1718
1719/// Extract table names from DROP TABLE statement
1720fn extract_drop_table_names(line: &str) -> Vec<String> {
1721    let line_upper = line.to_uppercase();
1722    let Some(rest) = line_upper.strip_prefix("DROP TABLE") else {
1723        return Vec::new();
1724    };
1725    let rest = rest.trim_start();
1726    let rest = if rest.starts_with("IF EXISTS") {
1727        match rest.strip_prefix("IF EXISTS") {
1728            Some(rest) => rest.trim_start(),
1729            None => return Vec::new(),
1730        }
1731    } else {
1732        rest
1733    };
1734
1735    split_sql_top_level_csv(&line[line.len() - rest.len()..])
1736        .into_iter()
1737        .filter_map(extract_sql_table_ref)
1738        .collect()
1739}
1740
1741/// Extract table and columns from ALTER TABLE ... DROP [COLUMN] actions.
1742fn extract_alter_drop_columns(line: &str) -> Vec<(String, String)> {
1743    let line_upper = line.to_uppercase();
1744    if !line_upper.starts_with("ALTER TABLE") {
1745        return Vec::new();
1746    }
1747    let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1748        return Vec::new();
1749    };
1750
1751    split_sql_top_level_csv(actions_part)
1752        .into_iter()
1753        .filter_map(|action| {
1754            extract_alter_drop_column_action(action).map(|col| (table.clone(), col))
1755        })
1756        .collect()
1757}
1758
1759fn extract_alter_drop_column_action(action: &str) -> Option<String> {
1760    let mut col_part = strip_sql_keyword(action, "DROP")?;
1761    col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1762    col_part = strip_sql_if_exists(col_part).unwrap_or(col_part);
1763
1764    let col_upper = col_part.trim_start().to_uppercase();
1765    if ["CONSTRAINT", "INDEX"].iter().any(|keyword| {
1766        col_upper.starts_with(keyword)
1767            && col_upper[keyword.len()..].starts_with(char::is_whitespace)
1768    }) {
1769        return None;
1770    }
1771
1772    extract_sql_column_ref(col_part.trim())
1773}
1774
1775fn extract_alter_rename_column(line: &str) -> Option<(String, String, String)> {
1776    let line_upper = line.to_uppercase();
1777    if !line_upper.starts_with("ALTER TABLE") {
1778        return None;
1779    }
1780    let (table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1781    let actions_upper = actions_part.to_uppercase();
1782    let (rename_pos, rename_len) = if let Some(pos) = actions_upper.find("RENAME COLUMN") {
1783        (pos, "RENAME COLUMN".len())
1784    } else {
1785        (actions_upper.find("RENAME ")?, "RENAME".len())
1786    };
1787    let to_pos = actions_upper[rename_pos + rename_len..].find(" TO ")? + rename_pos + rename_len;
1788
1789    let old_part = &actions_part[rename_pos + rename_len..to_pos];
1790    let new_part = &actions_part[to_pos + 4..];
1791    let old_col = extract_sql_column_ref(old_part.trim())?;
1792    let new_col = extract_sql_column_ref(new_part.trim())?;
1793
1794    Some((table, old_col, new_col))
1795}
1796
1797fn extract_alter_rename_table(line: &str) -> Option<(String, String)> {
1798    let line_upper = line.to_uppercase();
1799    if !line_upper.starts_with("ALTER TABLE") {
1800        return None;
1801    }
1802    let (old_table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1803    let actions_upper = actions_part.to_uppercase();
1804    let rename_pos = actions_upper.find("RENAME TO ")?;
1805
1806    let new_part = &actions_part[rename_pos + "RENAME TO ".len()..];
1807    let new_ref = extract_sql_table_ref(new_part.trim())?;
1808    let new_table = if new_ref.contains('.') {
1809        new_ref
1810    } else if let Some((schema, _)) = old_table.rsplit_once('.') {
1811        format!("{schema}.{new_ref}")
1812    } else {
1813        new_ref
1814    };
1815
1816    Some((old_table, new_table))
1817}
1818
1819fn extract_sql_table_ref(raw: &str) -> Option<String> {
1820    extract_sql_table_ref_with_tail(raw).map(|(name, _)| name)
1821}
1822
1823fn extract_sql_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1824    let mut rest = raw.trim_start();
1825    let mut parts = Vec::new();
1826
1827    loop {
1828        let (part, tail, _) = parse_sql_identifier_segment(rest)?;
1829        parts.push(part.to_ascii_lowercase());
1830        rest = tail.trim_start();
1831        if let Some(tail) = rest.strip_prefix('.') {
1832            rest = tail.trim_start();
1833        } else {
1834            break;
1835        }
1836    }
1837
1838    let name = parts.join(".");
1839    is_build_table_ref(&name).then_some((name, rest))
1840}
1841
1842fn extract_sql_column_ref(raw: &str) -> Option<String> {
1843    let (name, rest, quoted) = parse_sql_identifier_segment(raw)?;
1844    if rest.trim_start().starts_with('.') {
1845        return None;
1846    }
1847    let name = name.to_ascii_lowercase();
1848    if name.is_empty() || !is_build_identifier(&name) || (!quoted && name == "if") {
1849        None
1850    } else {
1851        Some(name)
1852    }
1853}
1854
1855fn parse_sql_identifier_segment(raw: &str) -> Option<(String, &str, bool)> {
1856    let rest = raw.trim_start();
1857    if let Some(quoted) = rest.strip_prefix('"') {
1858        let mut out = String::new();
1859        let mut chars = quoted.char_indices().peekable();
1860        while let Some((idx, ch)) = chars.next() {
1861            if ch == '"' {
1862                if chars.peek().is_some_and(|(_, next)| *next == '"') {
1863                    out.push('"');
1864                    chars.next();
1865                    continue;
1866                }
1867                let consumed = 1 + idx + ch.len_utf8();
1868                return Some((out, &rest[consumed..], true));
1869            }
1870            out.push(ch);
1871        }
1872        return None;
1873    }
1874
1875    let name: String = rest
1876        .chars()
1877        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1878        .collect();
1879    if name.is_empty() {
1880        return None;
1881    }
1882    let tail = &rest[name.len()..];
1883    Some((name, tail, false))
1884}
1885
1886fn extract_alter_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1887    let mut rest = raw.trim_start();
1888    let upper = rest.to_uppercase();
1889    if upper.starts_with("IF EXISTS")
1890        && rest
1891            .get("IF EXISTS".len()..)
1892            .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1893    {
1894        rest = rest.get("IF EXISTS".len()..)?.trim_start();
1895    }
1896    let upper = rest.to_uppercase();
1897    if upper.starts_with("ONLY")
1898        && rest
1899            .get("ONLY".len()..)
1900            .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1901    {
1902        rest = rest.get("ONLY".len()..)?.trim_start();
1903    }
1904    let (table, tail) = extract_sql_table_ref_with_tail(rest)?;
1905    Some((table, tail.trim_start()))
1906}
1907
1908impl TableSchema {
1909    /// Check if column exists
1910    pub fn has_column(&self, name: &str) -> bool {
1911        self.columns.contains_key(name)
1912    }
1913
1914    /// Get column type
1915    pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
1916        self.columns.get(name)
1917    }
1918
1919    /// Get the primary key column name for this table.
1920    ///
1921    /// Convention: returns `"id"` if it exists as a column.
1922    /// This is a single point of truth for PK resolution — when the schema
1923    /// parser is enhanced to track PK constraints, update this method.
1924    pub fn primary_key_column(&self) -> &str {
1925        if self.columns.contains_key("id") {
1926            "id"
1927        } else {
1928            // Fallback: look for `{singular_table_name}_id` pattern
1929            // e.g., table "users" → "user_id"
1930            let singular = self.name.trim_end_matches('s');
1931            let conventional = format!("{}_id", singular);
1932            if self.columns.contains_key(&conventional) {
1933                // Leak into 'static to satisfy lifetime — this is called rarely
1934                // and the string is small. Alternatively, return String.
1935                return "id"; // Safe default — schema has no "id" but this avoids lifetime issues
1936            }
1937            "id" // Universal fallback
1938        }
1939    }
1940}
1941
1942#[cfg(test)]
1943mod comment_tests {
1944    use super::{Schema, strip_schema_comments, strip_sql_line_comments};
1945
1946    #[test]
1947    fn schema_comment_stripping_ignores_markers_inside_quotes() {
1948        assert_eq!(
1949            strip_schema_comments(r#"status TEXT default 'draft--internal#tag' # comment"#),
1950            r#"status TEXT default 'draft--internal#tag'"#
1951        );
1952        assert_eq!(
1953            strip_schema_comments(r#"status TEXT default "draft--internal#tag" -- comment"#),
1954            r#"status TEXT default "draft--internal#tag""#
1955        );
1956    }
1957
1958    #[test]
1959    fn sql_comment_stripping_ignores_double_dash_inside_strings() {
1960        assert_eq!(
1961            strip_sql_line_comments("CREATE TABLE logs (message text DEFAULT 'a--b'); -- comment"),
1962            "CREATE TABLE logs (message text DEFAULT 'a--b');"
1963        );
1964        assert_eq!(
1965            strip_sql_line_comments("CREATE TABLE tags (name text DEFAULT '#not-comment');"),
1966            "CREATE TABLE tags (name text DEFAULT '#not-comment');"
1967        );
1968    }
1969
1970    #[test]
1971    fn sql_migration_paren_depth_ignores_string_literals() {
1972        let mut schema = Schema::default();
1973        schema.parse_sql_migration(
1974            r#"
1975CREATE TABLE logs (
1976  message text DEFAULT ')',
1977  tag text DEFAULT '(',
1978  level text
1979);
1980"#,
1981        );
1982
1983        let logs = schema.table("logs").expect("logs table should parse");
1984        assert!(logs.has_column("message"));
1985        assert!(logs.has_column("tag"));
1986        assert!(logs.has_column("level"));
1987    }
1988
1989    #[test]
1990    fn sql_migration_ignores_multiline_block_comments() {
1991        let mut schema = Schema::default();
1992        schema.parse_sql_migration(
1993            r#"
1994CREATE TABLE users (
1995  id uuid
1996);
1997
1998/*
1999ALTER TABLE users ADD COLUMN hidden text;
2000CREATE TABLE hidden_table (
2001  id uuid
2002);
2003*/
2004"#,
2005        );
2006
2007        let users = schema.table("users").expect("users table should parse");
2008        assert!(users.has_column("id"));
2009        assert!(!users.has_column("hidden"));
2010        assert!(!schema.has_table("hidden_table"));
2011    }
2012
2013    #[test]
2014    fn sql_migration_preserves_schema_qualified_table_names() {
2015        let mut schema = Schema::default();
2016        schema.parse_sql_migration(
2017            r#"
2018CREATE TABLE app.users (
2019  id uuid
2020);
2021
2022ALTER TABLE app.users ADD COLUMN email text;
2023"#,
2024        );
2025
2026        assert!(!schema.has_table("app"));
2027        let users = schema
2028            .table("app.users")
2029            .expect("schema-qualified table should parse");
2030        assert!(users.has_column("id"));
2031        assert!(users.has_column("email"));
2032    }
2033
2034    #[test]
2035    fn sql_migration_extracts_inline_create_table_columns() {
2036        let mut schema = Schema::default();
2037        schema.parse_sql_migration(
2038            "CREATE TABLE users (id uuid, email text DEFAULT 'a,b', CHECK (length(email) > 3));",
2039        );
2040
2041        let users = schema.table("users").expect("users table should parse");
2042        assert!(users.has_column("id"));
2043        assert!(users.has_column("email"));
2044        assert!(!users.has_column("check"));
2045    }
2046
2047    #[test]
2048    fn sql_migration_drops_multiple_tables() {
2049        let mut schema = Schema::default();
2050        schema.parse_sql_migration(
2051            r#"
2052CREATE TABLE app.users (id uuid);
2053CREATE TABLE app.posts (id uuid);
2054DROP TABLE IF EXISTS app.users, app.posts CASCADE;
2055"#,
2056        );
2057
2058        assert!(!schema.has_table("app.users"));
2059        assert!(!schema.has_table("app.posts"));
2060    }
2061
2062    #[test]
2063    fn sql_migration_ignores_create_table_non_column_clauses() {
2064        let mut schema = Schema::default();
2065        schema.parse_sql_migration(
2066            r#"
2067CREATE TABLE bookings (
2068  id uuid,
2069  EXCLUDE USING gist (room WITH =),
2070  LIKE booking_template INCLUDING ALL
2071);
2072"#,
2073        );
2074
2075        let bookings = schema
2076            .table("bookings")
2077            .expect("bookings table should parse");
2078        assert!(bookings.has_column("id"));
2079        assert!(!bookings.has_column("exclude"));
2080        assert!(!bookings.has_column("like"));
2081    }
2082
2083    #[test]
2084    fn sql_migration_ignores_alter_add_constraints() {
2085        let mut schema = Schema::default();
2086        schema.parse_sql_migration(
2087            r#"
2088CREATE TABLE users (id uuid, email text);
2089ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
2090ALTER TABLE users ADD PRIMARY KEY (id);
2091"#,
2092        );
2093
2094        let users = schema.table("users").expect("users table should parse");
2095        assert!(users.has_column("id"));
2096        assert!(users.has_column("email"));
2097        assert!(!users.has_column("constraint"));
2098        assert!(!users.has_column("primary"));
2099    }
2100
2101    #[test]
2102    fn sql_migration_handles_alter_table_modifiers() {
2103        let mut schema = Schema::default();
2104        schema.parse_sql_migration(
2105            r#"
2106CREATE TABLE users (id uuid);
2107ALTER TABLE ONLY users ADD COLUMN email text;
2108ALTER TABLE IF EXISTS users DROP COLUMN id;
2109"#,
2110        );
2111
2112        assert!(!schema.has_table("only"));
2113        assert!(!schema.has_table("if"));
2114        let users = schema.table("users").expect("users table should parse");
2115        assert!(!users.has_column("id"));
2116        assert!(users.has_column("email"));
2117    }
2118
2119    #[test]
2120    fn sql_migration_handles_drop_column_if_exists() {
2121        let mut schema = Schema::default();
2122        schema.parse_sql_migration(
2123            r#"
2124CREATE TABLE users (id uuid, old_email text, old_name text);
2125ALTER TABLE users DROP COLUMN IF EXISTS old_email;
2126ALTER TABLE users DROP IF EXISTS old_name;
2127"#,
2128        );
2129
2130        let users = schema.table("users").expect("users table should parse");
2131        assert!(users.has_column("id"));
2132        assert!(!users.has_column("old_email"));
2133        assert!(!users.has_column("old_name"));
2134        assert!(!users.has_column("if"));
2135    }
2136
2137    #[test]
2138    fn sql_migration_handles_quoted_table_and_column_identifiers() {
2139        let mut schema = Schema::default();
2140        schema.parse_sql_migration(
2141            r#"
2142CREATE TABLE "app"."order" ("id" uuid, "select" text);
2143ALTER TABLE "app"."order" ADD COLUMN "from" text;
2144ALTER TABLE "app"."order" DROP COLUMN "select";
2145"#,
2146        );
2147
2148        let orders = schema
2149            .table("app.order")
2150            .expect("quoted schema-qualified table should parse");
2151        assert!(orders.has_column("id"));
2152        assert!(orders.has_column("from"));
2153        assert!(!orders.has_column("select"));
2154    }
2155
2156    #[test]
2157    fn sql_migration_ignores_dollar_quoted_default_syntax() {
2158        let mut schema = Schema::default();
2159        schema.parse_sql_migration(
2160            r#"
2161CREATE TABLE logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2162"#,
2163        );
2164
2165        let logs = schema.table("logs").expect("logs table should parse");
2166        assert!(logs.has_column("id"));
2167        assert!(logs.has_column("body"));
2168        assert!(logs.has_column("tag"));
2169        assert!(!logs.has_column("b"));
2170        assert!(!logs.has_column("not"));
2171    }
2172
2173    #[test]
2174    fn sql_migration_ignores_multiline_dollar_quoted_bodies() {
2175        let mut schema = Schema::default();
2176        schema.parse_sql_migration(
2177            r#"
2178CREATE TABLE users (id uuid);
2179CREATE FUNCTION rebuild_hidden() RETURNS void AS $$
2180BEGIN
2181  CREATE TABLE hidden_from_function (id uuid);
2182END;
2183$$ LANGUAGE plpgsql;
2184"#,
2185        );
2186
2187        assert!(schema.has_table("users"));
2188        assert!(!schema.has_table("hidden_from_function"));
2189    }
2190
2191    #[test]
2192    fn sql_migration_handles_unlogged_create_tables() {
2193        let mut schema = Schema::default();
2194        schema.parse_sql_migration(
2195            r#"
2196CREATE UNLOGGED TABLE IF NOT EXISTS jobs (id uuid, status text);
2197CREATE TEMP TABLE scratch_jobs (id uuid);
2198"#,
2199        );
2200
2201        let jobs = schema.table("jobs").expect("unlogged table should parse");
2202        assert!(jobs.has_column("id"));
2203        assert!(jobs.has_column("status"));
2204        assert!(!schema.has_table("scratch_jobs"));
2205    }
2206
2207    #[test]
2208    fn sql_migration_tracks_column_renames() {
2209        let mut schema = Schema::default();
2210        schema.parse_sql_migration(
2211            r#"
2212CREATE TABLE users (id uuid, old_email text);
2213ALTER TABLE users RENAME COLUMN old_email TO email;
2214"#,
2215        );
2216
2217        let users = schema.table("users").expect("users table should parse");
2218        assert!(users.has_column("id"));
2219        assert!(users.has_column("email"));
2220        assert!(!users.has_column("old_email"));
2221    }
2222
2223    #[test]
2224    fn sql_migration_tracks_table_renames() {
2225        let mut schema = Schema::default();
2226        schema.parse_sql_migration(
2227            r#"
2228CREATE TABLE app.users (id uuid, email text);
2229ALTER TABLE app.users RENAME TO customers;
2230"#,
2231        );
2232
2233        assert!(!schema.has_table("app.users"));
2234        let customers = schema
2235            .table("app.customers")
2236            .expect("schema-qualified table rename should parse");
2237        assert!(customers.has_column("id"));
2238        assert!(customers.has_column("email"));
2239    }
2240
2241    #[test]
2242    fn sql_migration_handles_add_if_not_exists_without_column_keyword() {
2243        let mut schema = Schema::default();
2244        schema.parse_sql_migration(
2245            r#"
2246CREATE TABLE users (id uuid);
2247ALTER TABLE users ADD IF NOT EXISTS email text;
2248"#,
2249        );
2250
2251        let users = schema.table("users").expect("users table should parse");
2252        assert!(users.has_column("id"));
2253        assert!(users.has_column("email"));
2254        assert!(!users.has_column("if"));
2255    }
2256
2257    #[test]
2258    fn sql_migration_tracks_column_renames_without_column_keyword() {
2259        let mut schema = Schema::default();
2260        schema.parse_sql_migration(
2261            r#"
2262CREATE TABLE users (id uuid, old_email text);
2263ALTER TABLE users RENAME old_email TO email;
2264"#,
2265        );
2266
2267        let users = schema.table("users").expect("users table should parse");
2268        assert!(users.has_column("email"));
2269        assert!(!users.has_column("old_email"));
2270    }
2271
2272    #[test]
2273    fn sql_migration_does_not_treat_create_table_as_select_as_column_block() {
2274        let mut schema = Schema::default();
2275        schema.parse_sql_migration(
2276            r#"
2277CREATE TABLE reports AS SELECT id FROM users;
2278ALTER TABLE reports ADD COLUMN status text;
2279"#,
2280        );
2281
2282        let reports = schema.table("reports").expect("reports table should parse");
2283        assert!(reports.has_column("status"));
2284        assert!(!reports.has_column("alter"));
2285    }
2286
2287    #[test]
2288    fn sql_migration_handles_multiple_alter_add_actions() {
2289        let mut schema = Schema::default();
2290        schema.parse_sql_migration(
2291            r#"
2292CREATE TABLE users (id uuid);
2293ALTER TABLE users ADD COLUMN email text, ADD IF NOT EXISTS name text;
2294"#,
2295        );
2296
2297        let users = schema.table("users").expect("users table should parse");
2298        assert!(users.has_column("email"));
2299        assert!(users.has_column("name"));
2300    }
2301
2302    #[test]
2303    fn sql_migration_handles_multiple_alter_drop_actions() {
2304        let mut schema = Schema::default();
2305        schema.parse_sql_migration(
2306            r#"
2307CREATE TABLE users (id uuid, old_email text, old_name text);
2308ALTER TABLE users DROP COLUMN old_email, DROP IF EXISTS old_name;
2309"#,
2310        );
2311
2312        let users = schema.table("users").expect("users table should parse");
2313        assert!(users.has_column("id"));
2314        assert!(!users.has_column("old_email"));
2315        assert!(!users.has_column("old_name"));
2316    }
2317
2318    #[test]
2319    fn sql_migration_handles_multiline_mixed_alter_actions() {
2320        let mut schema = Schema::default();
2321        schema.parse_sql_migration(
2322            r#"
2323CREATE TABLE users (id uuid, old_email text, old_name text);
2324ALTER TABLE users
2325  ADD COLUMN email text,
2326  DROP COLUMN old_email,
2327  RENAME COLUMN old_name TO legacy_name;
2328"#,
2329        );
2330
2331        let users = schema.table("users").expect("users table should parse");
2332        assert!(users.has_column("id"));
2333        assert!(users.has_column("email"));
2334        assert!(users.has_column("legacy_name"));
2335        assert!(!users.has_column("old_email"));
2336        assert!(!users.has_column("old_name"));
2337    }
2338
2339    #[test]
2340    fn sql_migration_handles_drop_then_recreate_order() {
2341        let mut schema = Schema::default();
2342        schema.parse_sql_migration(
2343            r#"
2344CREATE TABLE users (stale text);
2345DROP TABLE users;
2346CREATE TABLE users (id uuid, email text);
2347"#,
2348        );
2349
2350        let users = schema
2351            .table("users")
2352            .expect("recreated table should remain in schema");
2353        assert!(users.has_column("id"));
2354        assert!(users.has_column("email"));
2355        assert!(!users.has_column("stale"));
2356    }
2357
2358    #[test]
2359    fn sql_migration_allows_alter_add_columns_with_constraint_prefixes() {
2360        let mut schema = Schema::default();
2361        schema.parse_sql_migration(
2362            r#"
2363CREATE TABLE users (id uuid);
2364ALTER TABLE users ADD COLUMN primary_contact text, ADD check_status text;
2365"#,
2366        );
2367
2368        let users = schema.table("users").expect("users table should parse");
2369        assert!(users.has_column("primary_contact"));
2370        assert!(users.has_column("check_status"));
2371    }
2372
2373    #[test]
2374    fn sql_migration_handles_create_table_paren_on_next_line() {
2375        let mut schema = Schema::default();
2376        schema.parse_sql_migration(
2377            r#"
2378CREATE TABLE users
2379(
2380  id uuid,
2381  email text
2382);
2383"#,
2384        );
2385
2386        let users = schema.table("users").expect("users table should parse");
2387        assert!(users.has_column("id"));
2388        assert!(users.has_column("email"));
2389    }
2390
2391    #[test]
2392    fn sql_migration_does_not_treat_alter_column_drop_as_column_drop() {
2393        let mut schema = Schema::default();
2394        schema.parse_sql_migration(
2395            r#"
2396CREATE TABLE users (id uuid, email text, not text);
2397ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
2398"#,
2399        );
2400
2401        let users = schema.table("users").expect("users table should parse");
2402        assert!(users.has_column("email"));
2403        assert!(users.has_column("not"));
2404    }
2405
2406    #[test]
2407    fn sql_migration_chaos_mixed_postgres_syntax() {
2408        let mut schema = Schema::default();
2409        schema.parse_sql_migration(
2410            r#"
2411CREATE SCHEMA app;
2412CREATE UNLOGGED TABLE IF NOT EXISTS "app"."users"
2413(
2414  id uuid,
2415  old_email text,
2416  old_name text,
2417  "select" text,
2418  "not" text
2419);
2420CREATE TEMP TABLE scratch_jobs (id uuid);
2421ALTER TABLE ONLY "app"."users" ADD COLUMN primary_contact text, ADD check_status text;
2422ALTER TABLE "app"."users" ADD IF NOT EXISTS guarded text;
2423ALTER TABLE "app"."users" DROP COLUMN "select", DROP IF EXISTS guarded, DROP COLUMN IF EXISTS old_name;
2424ALTER TABLE "app"."users" RENAME old_email TO email;
2425ALTER TABLE "app"."users" ALTER COLUMN email DROP NOT NULL;
2426ALTER TABLE "app"."users" RENAME TO customers;
2427
2428CREATE TABLE app.logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2429CREATE FUNCTION app.rebuild_hidden() RETURNS void AS $$
2430BEGIN
2431  CREATE TABLE hidden_from_function (id uuid);
2432END;
2433$$ LANGUAGE plpgsql;
2434CREATE TABLE app.reports AS SELECT id FROM app.customers;
2435ALTER TABLE app.reports ADD COLUMN status text;
2436"#,
2437        );
2438
2439        assert!(!schema.has_table("scratch_jobs"));
2440        assert!(!schema.has_table("app.users"));
2441        assert!(!schema.has_table("hidden_from_function"));
2442
2443        let customers = schema
2444            .table("app.customers")
2445            .expect("renamed schema-qualified table should parse");
2446        assert!(customers.has_column("id"));
2447        assert!(customers.has_column("email"));
2448        assert!(customers.has_column("not"));
2449        assert!(customers.has_column("primary_contact"));
2450        assert!(customers.has_column("check_status"));
2451        assert!(!customers.has_column("old_email"));
2452        assert!(!customers.has_column("old_name"));
2453        assert!(!customers.has_column("select"));
2454        assert!(!customers.has_column("guarded"));
2455
2456        let logs = schema.table("app.logs").expect("logs table should parse");
2457        assert!(logs.has_column("id"));
2458        assert!(logs.has_column("body"));
2459        assert!(logs.has_column("tag"));
2460        assert!(!logs.has_column("b"));
2461
2462        let reports = schema
2463            .table("app.reports")
2464            .expect("ctas table should parse");
2465        assert!(reports.has_column("status"));
2466        assert!(!reports.has_column("alter"));
2467    }
2468}