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                if matches!(line, "enable_rls" | "force_rls") {
454                    current_rls_flag = true;
455                    continue;
456                }
457
458                let parts: Vec<&str> = line.split_whitespace().collect();
459                if let Some(col_name) = parts.first() {
460                    if !is_build_identifier(col_name) {
461                        let table_name = current_table.as_deref().unwrap_or("<unknown>");
462                        return Err(format!(
463                            "Invalid column name '{}' in table '{}'",
464                            col_name, table_name
465                        ));
466                    }
467                    if current_columns.contains_key(*col_name) {
468                        let table_name = current_table.as_deref().unwrap_or("<unknown>");
469                        return Err(format!(
470                            "duplicate column '{}' in table '{}'",
471                            col_name, table_name
472                        ));
473                    }
474                    let table_name = current_table.as_deref().unwrap_or("<unknown>");
475                    let Some((col_type, type_end)) =
476                        parse_build_column_type_prefix(&parts, &enum_types)
477                    else {
478                        let Some(col_type_str) = parts.get(1).copied() else {
479                            return Err(format!(
480                                "Missing type for column '{}' in table '{}'",
481                                col_name, table_name
482                            ));
483                        };
484                        return Err(format!(
485                            "Unknown column type '{}' for column '{}' in table '{}'",
486                            col_type_str, col_name, table_name
487                        ));
488                    };
489                    current_columns.insert(col_name.to_string(), col_type);
490
491                    // Check for policies and foreign keys
492                    let mut policy = "Public".to_string();
493                    let mut seen_protected = false;
494                    let mut seen_column_options = HashSet::new();
495                    let mut nullability_option: Option<&str> = None;
496                    let mut generated_option: Option<&str> = None;
497                    let mut has_foreign_key = false;
498                    let mut seen_fk_actions = HashSet::new();
499
500                    let mut i = type_end;
501                    while i < parts.len() {
502                        let part = parts[i];
503                        if part == "protected" {
504                            if seen_protected {
505                                return Err(format!(
506                                    "duplicate protected option for column '{}' in table '{}'",
507                                    col_name, table_name
508                                ));
509                            }
510                            seen_protected = true;
511                            policy = "Protected".to_string();
512                        } else if matches!(
513                            part,
514                            "primary_key"
515                                | "not_null"
516                                | "nullable"
517                                | "unique"
518                                | "generated_identity"
519                                | "generated_by_default_identity"
520                        ) {
521                            if !seen_column_options.insert(part) {
522                                return Err(format!(
523                                    "duplicate column option '{}' for column '{}' in table '{}'",
524                                    part, col_name, table_name
525                                ));
526                            }
527                            if matches!(part, "not_null" | "nullable") {
528                                if let Some(existing) = nullability_option {
529                                    return Err(format!(
530                                        "conflicting nullability options '{}' and '{}' for column '{}' in table '{}'",
531                                        existing, part, col_name, table_name
532                                    ));
533                                }
534                                nullability_option = Some(part);
535                            }
536                            if matches!(
537                                part,
538                                "generated_identity" | "generated_by_default_identity"
539                            ) {
540                                if let Some(existing) = generated_option {
541                                    return Err(format!(
542                                        "conflicting generated options '{}' and '{}' for column '{}' in table '{}'",
543                                        existing, part, col_name, table_name
544                                    ));
545                                }
546                                generated_option = Some(part);
547                            }
548                            // Build-time validation only needs shape, type, policy, and relations.
549                        } else if part == "default" {
550                            if i + 1 >= parts.len() {
551                                return Err(format!(
552                                    "default requires a value for column '{}' in table '{}'",
553                                    col_name, table_name
554                                ));
555                            }
556                            break;
557                        } else if part.starts_with("default=")
558                            || part.starts_with("default:")
559                            || part.starts_with("generated_stored(")
560                            || part.starts_with("check(")
561                        {
562                            break;
563                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
564                            // Parse "table.column" or ">table.column"
565                            let (ref_table, ref_column) =
566                                parse_build_ref_spec(ref_spec, col_name, table_name)?;
567                            push_build_foreign_key(
568                                &mut current_fks,
569                                col_name,
570                                ref_table,
571                                ref_column,
572                                table_name,
573                            )?;
574                            has_foreign_key = true;
575                        } else if part == "references" {
576                            if i + 1 >= parts.len() {
577                                return Err(format!(
578                                    "foreign key reference target is required for column '{}' in table '{}'",
579                                    col_name, table_name
580                                ));
581                            }
582                            i += 1;
583                            let (ref_table, ref_column) =
584                                parse_build_references_target(parts[i], col_name, table_name)?;
585                            push_build_foreign_key(
586                                &mut current_fks,
587                                col_name,
588                                ref_table,
589                                ref_column,
590                                table_name,
591                            )?;
592                            has_foreign_key = true;
593                        } else if let Some(ref_target) = part.strip_prefix("references") {
594                            let (ref_table, ref_column) =
595                                parse_build_references_target(ref_target, col_name, table_name)?;
596                            push_build_foreign_key(
597                                &mut current_fks,
598                                col_name,
599                                ref_table,
600                                ref_column,
601                                table_name,
602                            )?;
603                            has_foreign_key = true;
604                        } else if matches!(part, "on_delete" | "on_update") {
605                            if !has_foreign_key {
606                                return Err(format!(
607                                    "{} requires a preceding foreign key for column '{}' in table '{}'",
608                                    part, col_name, table_name
609                                ));
610                            }
611                            if !seen_fk_actions.insert(part) {
612                                return Err(format!(
613                                    "duplicate {} action for column '{}' in table '{}'",
614                                    part, col_name, table_name
615                                ));
616                            }
617                            if i + 1 >= parts.len() {
618                                return Err(format!(
619                                    "{} requires a foreign key action for column '{}' in table '{}'",
620                                    part, col_name, table_name
621                                ));
622                            }
623                            i += 1;
624                            if !is_build_fk_action(parts[i]) {
625                                return Err(format!(
626                                    "unknown foreign key action '{}' for column '{}' in table '{}'",
627                                    parts[i], col_name, table_name
628                                ));
629                            }
630                        } else if part == "check_name" {
631                            if i + 1 >= parts.len() {
632                                return Err(format!(
633                                    "check_name requires a name for column '{}' in table '{}'",
634                                    col_name, table_name
635                                ));
636                            }
637                            i += 1;
638                        } else {
639                            return Err(format!(
640                                "Unknown column option '{}' for column '{}' in table '{}'",
641                                part, col_name, table_name
642                            ));
643                        }
644                        i += 1;
645                    }
646                    current_policies.insert(col_name.to_string(), policy);
647                }
648            }
649        }
650
651        if let Some(table_name) = current_table.take() {
652            return Err(format!(
653                "Unclosed table definition for '{}': expected closing '}}'",
654                table_name
655            ));
656        }
657
658        Ok(schema)
659    }
660
661    /// Resolve a table/view reference against the build schema.
662    ///
663    /// Exact names win. As a Postgres convenience, bare names and
664    /// `public.<name>` are treated as aliases only when the exact name is not
665    /// present. Non-public schema-qualified names stay distinct.
666    pub fn resolve_table_name(&self, name: &str) -> Option<&str> {
667        if let Some((key, _)) = self.tables.get_key_value(name) {
668            return Some(key.as_str());
669        }
670        if let Some(view) = self.views.get(name) {
671            return Some(view.as_str());
672        }
673
674        if let Some(bare) = name.strip_prefix("public.") {
675            if bare.is_empty() {
676                return None;
677            }
678            if let Some((key, _)) = self.tables.get_key_value(bare) {
679                return Some(key.as_str());
680            }
681            if let Some(view) = self.views.get(bare) {
682                return Some(view.as_str());
683            }
684            return None;
685        }
686
687        if !name.contains('.') {
688            let qualified = format!("public.{name}");
689            if let Some((key, _)) = self.tables.get_key_value(&qualified) {
690                return Some(key.as_str());
691            }
692            if let Some(view) = self.views.get(&qualified) {
693                return Some(view.as_str());
694            }
695        }
696
697        None
698    }
699
700    /// Check if table exists
701    pub fn has_table(&self, name: &str) -> bool {
702        self.resolve_table_name(name).is_some()
703    }
704
705    /// Get all table names that have RLS enabled
706    pub fn rls_tables(&self) -> Vec<&str> {
707        self.tables
708            .iter()
709            .filter(|(_, ts)| ts.rls_enabled)
710            .map(|(name, _)| name.as_str())
711            .collect()
712    }
713
714    /// Check if a specific table has RLS enabled
715    pub fn is_rls_table(&self, name: &str) -> bool {
716        self.table(name).is_some_and(|t| t.rls_enabled)
717    }
718
719    /// Get table schema
720    pub fn table(&self, name: &str) -> Option<&TableSchema> {
721        let resolved = self.resolve_table_name(name)?;
722        self.tables.get(resolved)
723    }
724
725    /// Merge pending migrations into the schema
726    /// Scans migration directory for:
727    /// - legacy SQL migrations (`up.sql` / `*.sql`)
728    /// - native QAIL migrations (`up.qail` / `*.qail`)
729    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
730        use std::fs;
731
732        let dir = Path::new(migrations_dir);
733        if !dir.exists() {
734            return Ok(0); // No migrations directory
735        }
736
737        let mut merged_count = 0;
738
739        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
740        let entries =
741            fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
742
743        for entry in entries.flatten() {
744            let path = entry.path();
745
746            // Check for migration file candidates in subdirectory (prefer native QAIL),
747            // or direct file entries.
748            let migration_file = if path.is_dir() {
749                let up_qail = path.join("up.qail");
750                let up_sql = path.join("up.sql");
751                if up_qail.exists() {
752                    up_qail
753                } else if up_sql.exists() {
754                    up_sql
755                } else {
756                    continue;
757                }
758            } else if path.extension().is_some_and(|e| e == "qail" || e == "sql") {
759                path.clone()
760            } else {
761                continue;
762            };
763
764            if migration_file.exists() {
765                let content = fs::read_to_string(&migration_file)
766                    .map_err(|e| format!("Failed to read {}: {}", migration_file.display(), e))?;
767
768                if migration_file.extension().is_some_and(|ext| ext == "qail") {
769                    merged_count += self.parse_qail_migration(&content).map_err(|e| {
770                        format!(
771                            "Failed to parse native migration {}: {}",
772                            migration_file.display(),
773                            e
774                        )
775                    })?;
776                } else {
777                    merged_count += self.parse_sql_migration(&content);
778                }
779            }
780        }
781
782        Ok(merged_count)
783    }
784
785    /// Parse native QAIL migration content and merge tables/columns into build schema.
786    pub(crate) fn parse_qail_migration(&mut self, qail: &str) -> Result<usize, String> {
787        let parsed = Schema::parse(qail)?;
788        let mut changes = 0usize;
789
790        for (table_name, parsed_table) in parsed.tables {
791            if let Some(existing) = self.tables.get_mut(&table_name) {
792                for (col_name, col_type) in parsed_table.columns {
793                    if let Some(existing_type) = existing.columns.get(&col_name) {
794                        if existing_type != &col_type {
795                            return Err(format!(
796                                "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
797                                table_name, col_name, existing_type, col_type
798                            ));
799                        }
800                    } else {
801                        existing.columns.insert(col_name.clone(), col_type);
802                        changes += 1;
803                    }
804                }
805                for (col_name, policy) in parsed_table.policies {
806                    if existing.policies.insert(col_name, policy).is_none() {
807                        changes += 1;
808                    }
809                }
810                for fk in parsed_table.foreign_keys {
811                    let duplicate = existing.foreign_keys.iter().any(|existing_fk| {
812                        existing_fk.column == fk.column
813                            && existing_fk.ref_table == fk.ref_table
814                            && existing_fk.ref_column == fk.ref_column
815                    });
816                    if !duplicate {
817                        existing.foreign_keys.push(fk);
818                        changes += 1;
819                    }
820                }
821                if parsed_table.rls_enabled && !existing.rls_enabled {
822                    existing.rls_enabled = true;
823                    changes += 1;
824                }
825            } else {
826                changes += 1 + parsed_table.columns.len();
827                self.tables.insert(table_name, parsed_table);
828            }
829        }
830
831        for view_name in parsed.views {
832            if self.views.insert(view_name) {
833                changes += 1;
834            }
835        }
836        for (resource_name, resource) in parsed.resources {
837            if self.resources.insert(resource_name, resource).is_none() {
838                changes += 1;
839            }
840        }
841
842        changes += self.parse_explicit_qail_apply_commands(qail)?;
843
844        Ok(changes)
845    }
846
847    fn parse_explicit_qail_apply_commands(&mut self, qail: &str) -> Result<usize, String> {
848        let mut changes = 0usize;
849
850        for (line_no, raw_line) in qail.lines().enumerate() {
851            let line = strip_schema_comments(raw_line);
852            if line.is_empty() || !line.starts_with("alter ") {
853                continue;
854            }
855
856            let (table, column_name, column_type) = parse_explicit_alter_add_column_line(line)
857                .map_err(|err| format!("Line {}: {}", line_no + 1, err))?;
858
859            if let Some(existing) = self.tables.get_mut(&table) {
860                if let Some(existing_type) = existing.columns.get(&column_name) {
861                    if existing_type != &column_type {
862                        return Err(format!(
863                            "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
864                            table, column_name, existing_type, column_type
865                        ));
866                    }
867                } else {
868                    existing.columns.insert(column_name, column_type);
869                    changes += 1;
870                }
871            } else {
872                let mut columns = HashMap::new();
873                columns.insert(column_name, column_type);
874                self.tables.insert(
875                    table.clone(),
876                    TableSchema {
877                        name: table,
878                        columns,
879                        policies: HashMap::new(),
880                        foreign_keys: vec![],
881                        rls_enabled: false,
882                    },
883                );
884                changes += 2;
885            }
886        }
887
888        Ok(changes)
889    }
890
891    /// Parse SQL migration content and extract schema changes
892    pub(crate) fn parse_sql_migration(&mut self, sql: &str) -> usize {
893        let mut changes = 0;
894
895        for statement in sql_migration_statements(sql) {
896            let line = statement.as_str();
897            let line_upper = line.to_uppercase();
898
899            if let Some((name, after_table_name)) = extract_create_table_name_with_tail(line) {
900                let table_existed = self.tables.contains_key(&name);
901                if !table_existed {
902                    self.tables.insert(
903                        name.clone(),
904                        TableSchema {
905                            name: name.clone(),
906                            columns: HashMap::new(),
907                            policies: HashMap::new(),
908                            foreign_keys: vec![],
909                            rls_enabled: false,
910                        },
911                    );
912                    changes += 1;
913                }
914
915                let after_table_name = after_table_name.trim_start();
916                let has_column_block =
917                    after_table_name.is_empty() || after_table_name.starts_with('(');
918                // Only track column extraction for tables that DON'T already
919                // have their types from schema.qail. Tables that existed before
920                // this migration already have correct types; overwriting them
921                // with ColumnType::Text would be a bug.
922                if has_column_block
923                    && (!table_existed
924                        || self.tables.get(&name).is_some_and(|t| t.columns.is_empty()))
925                {
926                    for col in extract_inline_create_columns(line) {
927                        if let Some(t) = self.tables.get_mut(&name)
928                            && t.columns.insert(col, ColumnType::Text).is_none()
929                        {
930                            changes += 1;
931                        }
932                    }
933                }
934                continue;
935            }
936
937            // ALTER TABLE ... ADD [COLUMN] ...
938            for (table, col) in extract_alter_add_columns(line) {
939                if let Some(t) = self.tables.get_mut(&table) {
940                    if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
941                        changes += 1;
942                    }
943                } else {
944                    // Table might be new from this migration
945                    let mut cols = HashMap::new();
946                    cols.insert(col, ColumnType::Text);
947                    self.tables.insert(
948                        table.clone(),
949                        TableSchema {
950                            name: table,
951                            columns: cols,
952                            policies: HashMap::new(),
953                            foreign_keys: vec![],
954                            rls_enabled: false,
955                        },
956                    );
957                    changes += 1;
958                }
959            }
960
961            // DROP TABLE
962            if line_upper.starts_with("DROP TABLE") {
963                for table_name in extract_drop_table_names(line) {
964                    if self.tables.remove(&table_name).is_some() {
965                        changes += 1;
966                    }
967                }
968            }
969
970            // ALTER TABLE ... DROP [COLUMN] ...
971            for (table, col) in extract_alter_drop_columns(line) {
972                if let Some(t) = self.tables.get_mut(&table)
973                    && t.columns.remove(&col).is_some()
974                {
975                    changes += 1;
976                }
977            }
978
979            // ALTER TABLE ... RENAME COLUMN old TO new
980            if line_upper.starts_with("ALTER TABLE")
981                && let Some((table, old_col, new_col)) = extract_alter_rename_column(line)
982                && let Some(t) = self.tables.get_mut(&table)
983            {
984                let old_type = t.columns.remove(&old_col);
985                if old_type.is_some() {
986                    changes += 1;
987                }
988                if t.columns
989                    .insert(new_col, old_type.unwrap_or(ColumnType::Text))
990                    .is_none()
991                {
992                    changes += 1;
993                }
994            }
995
996            // ALTER TABLE ... RENAME TO new_table
997            if line_upper.starts_with("ALTER TABLE")
998                && let Some((old_table, new_table)) = extract_alter_rename_table(line)
999                && !self.tables.contains_key(&new_table)
1000                && let Some(mut table) = self.tables.remove(&old_table)
1001            {
1002                table.name = new_table.clone();
1003                self.tables.insert(new_table, table);
1004                changes += 1;
1005            }
1006        }
1007
1008        changes
1009    }
1010}
1011
1012fn sql_migration_statements(sql: &str) -> Vec<String> {
1013    let mut cleaned = String::new();
1014    let mut in_block_comment = false;
1015    let mut dollar_quote = None;
1016
1017    for raw_line in sql.lines() {
1018        let line = strip_sql_migration_comments(raw_line, &mut in_block_comment, &mut dollar_quote);
1019        if line.is_empty() {
1020            continue;
1021        }
1022        cleaned.push_str(&line);
1023        cleaned.push('\n');
1024    }
1025
1026    split_sql_statements(&cleaned)
1027}
1028
1029fn parse_build_column_type_prefix(
1030    parts: &[&str],
1031    enum_types: &HashMap<String, Vec<String>>,
1032) -> Option<(ColumnType, usize)> {
1033    let max_end = parts.len().min(5);
1034    for end in (2..=max_end).rev() {
1035        let type_str = parts[1..end].join(" ");
1036        if let Ok(column_type) = type_str.parse::<ColumnType>() {
1037            return Some((column_type, end));
1038        }
1039        if let Some(values) = enum_types.get(&type_str) {
1040            return Some((
1041                ColumnType::Enum {
1042                    name: type_str,
1043                    values: values.clone(),
1044                },
1045                end,
1046            ));
1047        }
1048    }
1049    None
1050}
1051
1052fn parse_build_enum_declaration<'a, I: Iterator<Item = &'a str>>(
1053    first_line: &str,
1054    lines: &mut std::iter::Peekable<I>,
1055) -> Result<(String, Vec<String>), String> {
1056    let rest = first_line
1057        .strip_prefix("enum ")
1058        .ok_or_else(|| "Expected 'enum' prefix".to_string())?
1059        .trim();
1060    let (name, body_start) = rest
1061        .split_once('{')
1062        .ok_or_else(|| "enum definition requires { values }".to_string())?;
1063    let name = name.trim();
1064    if name.is_empty() {
1065        return Err("enum name is missing before '{'".to_string());
1066    }
1067    if !is_build_table_ref(name) {
1068        return Err(format!("Invalid enum name '{}'", name));
1069    }
1070
1071    let mut body = body_start.to_string();
1072    while build_enum_body_before_closing_brace(&body)?.is_none() {
1073        let Some(next_line) = lines.next() else {
1074            return Err(format!("enum '{}' is missing closing '}}'", name));
1075        };
1076        let inner = strip_schema_comments(next_line);
1077        body.push(' ');
1078        body.push_str(inner);
1079    }
1080
1081    let body = build_enum_body_before_closing_brace(&body)?
1082        .ok_or_else(|| format!("enum '{}' is missing closing '}}'", name))?;
1083    let values = parse_build_enum_values(body)?;
1084    if values.is_empty() {
1085        return Err(format!("enum '{}' must have at least one value", name));
1086    }
1087
1088    Ok((name.to_string(), values))
1089}
1090
1091fn build_enum_body_before_closing_brace(raw: &str) -> Result<Option<&str>, String> {
1092    let mut quote: Option<char> = None;
1093    let mut chars = raw.char_indices().peekable();
1094
1095    while let Some((idx, ch)) = chars.next() {
1096        if let Some(q) = quote {
1097            if ch == q {
1098                if chars.peek().is_some_and(|(_, next)| *next == q) {
1099                    chars.next();
1100                } else {
1101                    quote = None;
1102                }
1103            }
1104            continue;
1105        }
1106
1107        match ch {
1108            '\'' | '"' => quote = Some(ch),
1109            '}' => {
1110                let rest = &raw[idx + ch.len_utf8()..];
1111                if !rest.trim().is_empty() {
1112                    return Err("trailing content after enum block".to_string());
1113                }
1114                return Ok(Some(&raw[..idx]));
1115            }
1116            _ => {}
1117        }
1118    }
1119
1120    Ok(None)
1121}
1122
1123fn parse_build_enum_values(raw: &str) -> Result<Vec<String>, String> {
1124    let mut values = Vec::new();
1125    let mut quote: Option<char> = None;
1126    let mut start = 0;
1127    let mut chars = raw.char_indices().peekable();
1128
1129    while let Some((idx, ch)) = chars.next() {
1130        if let Some(q) = quote {
1131            if ch == q {
1132                if chars.peek().is_some_and(|(_, next)| *next == q) {
1133                    chars.next();
1134                } else {
1135                    quote = None;
1136                }
1137            }
1138            continue;
1139        }
1140
1141        match ch {
1142            '\'' | '"' => quote = Some(ch),
1143            ',' => {
1144                push_build_enum_value(&mut values, &raw[start..idx])?;
1145                start = idx + ch.len_utf8();
1146            }
1147            _ => {}
1148        }
1149    }
1150
1151    if quote.is_some() {
1152        return Err("unterminated quoted enum value".to_string());
1153    }
1154
1155    push_build_enum_value(&mut values, &raw[start..])?;
1156    let mut seen = HashSet::new();
1157    for value in &values {
1158        if !seen.insert(value) {
1159            return Err(format!("duplicate enum value '{}'", value));
1160        }
1161    }
1162
1163    Ok(values)
1164}
1165
1166fn push_build_enum_value(values: &mut Vec<String>, raw: &str) -> Result<(), String> {
1167    let was_quoted = raw
1168        .trim()
1169        .chars()
1170        .next()
1171        .is_some_and(|ch| matches!(ch, '\'' | '"'));
1172    let value = parse_build_enum_value(raw)?;
1173    if value.is_empty() && !was_quoted {
1174        return Err("enum value is empty".to_string());
1175    }
1176    values.push(value);
1177    Ok(())
1178}
1179
1180fn parse_build_enum_value(raw: &str) -> Result<String, String> {
1181    let trimmed = raw.trim();
1182    if trimmed.is_empty() {
1183        return Ok(String::new());
1184    }
1185
1186    if let Some(quote) = trimmed.chars().next().filter(|ch| matches!(ch, '"' | '\'')) {
1187        let mut value = String::new();
1188        let mut chars = trimmed.char_indices();
1189        chars.next();
1190        let mut chars = chars.peekable();
1191
1192        while let Some((idx, ch)) = chars.next() {
1193            if ch == quote {
1194                if chars.peek().is_some_and(|(_, next)| *next == quote) {
1195                    value.push(quote);
1196                    chars.next();
1197                    continue;
1198                }
1199
1200                let after = idx + ch.len_utf8();
1201                if !trimmed[after..].trim().is_empty() {
1202                    return Err(format!("invalid enum value token '{}'", trimmed));
1203                }
1204                return Ok(value);
1205            }
1206
1207            value.push(ch);
1208        }
1209
1210        return Err("unterminated quoted enum value".to_string());
1211    }
1212
1213    if trimmed
1214        .chars()
1215        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1216    {
1217        return Ok(trimmed.to_string());
1218    }
1219
1220    Err(format!("invalid enum value token '{}'", trimmed))
1221}
1222
1223fn parse_build_references_target(
1224    target: &str,
1225    col_name: &str,
1226    table_name: &str,
1227) -> Result<(String, String), String> {
1228    let target = target.trim();
1229    let (ref_table, ref_column) = target.split_once('(').ok_or_else(|| {
1230        format!(
1231            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1232            target, col_name, table_name
1233        )
1234    })?;
1235    let ref_column = ref_column.strip_suffix(')').ok_or_else(|| {
1236        format!(
1237            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1238            target, col_name, table_name
1239        )
1240    })?;
1241    let ref_table = ref_table.trim();
1242    let ref_column = ref_column.trim();
1243    if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1244        return Err(format!(
1245            "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1246            target, col_name, table_name
1247        ));
1248    }
1249
1250    Ok((ref_table.to_string(), ref_column.to_string()))
1251}
1252
1253fn parse_build_ref_spec(
1254    ref_spec: &str,
1255    col_name: &str,
1256    table_name: &str,
1257) -> Result<(String, String), String> {
1258    let ref_spec = ref_spec.trim_start_matches('>');
1259    let (ref_table, ref_column) = ref_spec.split_once('.').ok_or_else(|| {
1260        format!(
1261            "Invalid ref target '{}' for column '{}' in table '{}'",
1262            ref_spec, col_name, table_name
1263        )
1264    })?;
1265    let ref_table = ref_table.trim();
1266    let ref_column = ref_column.trim();
1267    if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1268        return Err(format!(
1269            "Invalid ref target '{}' for column '{}' in table '{}'",
1270            ref_spec, col_name, table_name
1271        ));
1272    }
1273
1274    Ok((ref_table.to_string(), ref_column.to_string()))
1275}
1276
1277fn push_build_foreign_key(
1278    foreign_keys: &mut Vec<ForeignKey>,
1279    column: &str,
1280    ref_table: String,
1281    ref_column: String,
1282    table_name: &str,
1283) -> Result<(), String> {
1284    if foreign_keys
1285        .iter()
1286        .any(|fk| fk.column == column && fk.ref_table == ref_table && fk.ref_column == ref_column)
1287    {
1288        return Err(format!(
1289            "duplicate foreign key '{}.{} -> {}.{}'",
1290            table_name, column, ref_table, ref_column
1291        ));
1292    }
1293
1294    foreign_keys.push(ForeignKey {
1295        column: column.to_string(),
1296        ref_table,
1297        ref_column,
1298    });
1299    Ok(())
1300}
1301
1302fn is_build_table_ref(value: &str) -> bool {
1303    let mut parts = value.split('.');
1304    let Some(first) = parts.next() else {
1305        return false;
1306    };
1307    !first.is_empty() && is_build_identifier(first) && parts.all(is_build_identifier)
1308}
1309
1310fn is_build_identifier(value: &str) -> bool {
1311    !value.is_empty()
1312        && value
1313            .chars()
1314            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1315}
1316
1317fn is_build_fk_action(value: &str) -> bool {
1318    matches!(
1319        value,
1320        "cascade" | "set_null" | "set_default" | "restrict" | "no_action"
1321    )
1322}
1323
1324fn resource_block_content_before_closing(content: &str) -> Result<Option<String>, String> {
1325    let mut quote: Option<char> = None;
1326    let mut escaped = false;
1327
1328    for (idx, ch) in content.char_indices() {
1329        if escaped {
1330            escaped = false;
1331            continue;
1332        }
1333
1334        match quote {
1335            Some(q) => match ch {
1336                '\\' => escaped = true,
1337                c if c == q => quote = None,
1338                _ => {}
1339            },
1340            None => match ch {
1341                '"' | '\'' => quote = Some(ch),
1342                '}' => {
1343                    let rest = &content[idx + ch.len_utf8()..];
1344                    if !rest.trim().is_empty() {
1345                        return Err("Trailing content after resource definition".to_string());
1346                    }
1347                    return Ok(Some(content[..idx].trim().to_string()));
1348                }
1349                _ => {}
1350            },
1351        }
1352    }
1353
1354    Ok(None)
1355}
1356
1357fn split_resource_tokens(content: &str) -> Result<Vec<String>, String> {
1358    let mut tokens = Vec::new();
1359    let mut current = String::new();
1360    let mut quote: Option<char> = None;
1361    let mut escaped = false;
1362
1363    for ch in content.chars() {
1364        if escaped {
1365            current.push(ch);
1366            escaped = false;
1367            continue;
1368        }
1369
1370        match quote {
1371            Some(q) => match ch {
1372                '\\' => escaped = true,
1373                c if c == q => quote = None,
1374                c => current.push(c),
1375            },
1376            None => match ch {
1377                '"' | '\'' => quote = Some(ch),
1378                c if c.is_whitespace() => {
1379                    if !current.is_empty() {
1380                        tokens.push(std::mem::take(&mut current));
1381                    }
1382                }
1383                c => current.push(c),
1384            },
1385        }
1386    }
1387
1388    if escaped {
1389        current.push('\\');
1390    }
1391    if quote.is_some() {
1392        return Err("Unterminated quoted resource value".to_string());
1393    }
1394    if !current.is_empty() {
1395        tokens.push(current);
1396    }
1397
1398    Ok(tokens)
1399}
1400
1401fn parse_explicit_alter_add_column_line(
1402    line: &str,
1403) -> Result<(String, String, ColumnType), String> {
1404    let rest = line
1405        .strip_prefix("alter ")
1406        .ok_or_else(|| "expected 'alter <table> add <column:type[:constraints]>'".to_string())?
1407        .trim();
1408
1409    let mut parts = rest.splitn(2, char::is_whitespace);
1410    let table = parts
1411        .next()
1412        .map(str::trim)
1413        .filter(|table| !table.is_empty())
1414        .ok_or_else(|| "expected table name after 'alter'".to_string())?;
1415    if !is_build_table_ref(table) {
1416        return Err(format!("invalid alter table name '{}'", table));
1417    }
1418    let remainder = parts
1419        .next()
1420        .map(str::trim)
1421        .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?;
1422    let column_def = remainder
1423        .strip_prefix("add ")
1424        .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?
1425        .trim();
1426
1427    if column_def.is_empty() {
1428        return Err("expected column definition after 'add'".to_string());
1429    }
1430
1431    let (remaining, column_expr) = parse_column_definition(column_def)
1432        .map_err(|_| format!("invalid column definition '{}'", column_def))?;
1433    if !remaining.trim().is_empty() {
1434        return Err(format!(
1435            "unexpected trailing content after column definition: '{}'",
1436            remaining.trim()
1437        ));
1438    }
1439
1440    match column_expr {
1441        Expr::Def {
1442            name, data_type, ..
1443        } => {
1444            let column_type = data_type.parse::<ColumnType>().map_err(|_| {
1445                format!(
1446                    "unknown column type '{}' for column '{}' in alter '{}'",
1447                    data_type, name, table
1448                )
1449            })?;
1450            Ok((table.to_string(), name, column_type))
1451        }
1452        _ => Err("expected column definition after 'add'".to_string()),
1453    }
1454}
1455
1456fn extract_view_name(line: &str) -> Option<&str> {
1457    let rest = if let Some(r) = line.strip_prefix("view ") {
1458        r
1459    } else {
1460        line.strip_prefix("materialized view ")?
1461    };
1462
1463    let name = rest.split_whitespace().next().unwrap_or_default().trim();
1464    if name.is_empty() { None } else { Some(name) }
1465}
1466
1467fn extract_create_table_name_with_tail(line: &str) -> Option<(String, &str)> {
1468    let rest = extract_create_table_target_start(line)?;
1469    let rest = strip_sql_if_not_exists(rest).unwrap_or(rest);
1470
1471    extract_sql_table_ref_with_tail(rest)
1472}
1473
1474fn extract_create_table_target_start(line: &str) -> Option<&str> {
1475    let mut rest = strip_sql_keyword(line, "CREATE")?;
1476
1477    if let Some(after_unlogged) = strip_sql_keyword(rest, "UNLOGGED") {
1478        rest = after_unlogged;
1479    } else if strip_sql_keyword(rest, "TEMP")
1480        .or_else(|| strip_sql_keyword(rest, "TEMPORARY"))
1481        .is_some()
1482    {
1483        return None;
1484    }
1485
1486    strip_sql_keyword(rest, "TABLE")
1487}
1488
1489fn strip_sql_keyword<'a>(raw: &'a str, keyword: &str) -> Option<&'a str> {
1490    let rest = raw.trim_start();
1491    let tail = rest.get(keyword.len()..)?;
1492    if rest[..keyword.len()].eq_ignore_ascii_case(keyword)
1493        && (tail.is_empty() || tail.starts_with(char::is_whitespace))
1494    {
1495        Some(tail.trim_start())
1496    } else {
1497        None
1498    }
1499}
1500
1501fn strip_sql_if_exists(raw: &str) -> Option<&str> {
1502    let after_if = strip_sql_keyword(raw, "IF")?;
1503    strip_sql_keyword(after_if, "EXISTS")
1504}
1505
1506fn strip_sql_if_not_exists(raw: &str) -> Option<&str> {
1507    let after_if = strip_sql_keyword(raw, "IF")?;
1508    let after_not = strip_sql_keyword(after_if, "NOT")?;
1509    strip_sql_keyword(after_not, "EXISTS")
1510}
1511
1512/// Extract column name from a line inside CREATE TABLE block
1513fn extract_column_from_create(line: &str) -> Option<String> {
1514    let line = line.trim();
1515
1516    // Skip keywords and constraints
1517    // IMPORTANT: Must check for word boundaries to avoid matching column names
1518    // that happen to start with a keyword (e.g., created_at starts with CREATE,
1519    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
1520    let line_upper = line.to_uppercase();
1521    let starts_with_keyword = |kw: &str| -> bool {
1522        line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
1523    };
1524
1525    if starts_with_keyword("CREATE")
1526        || starts_with_keyword("PRIMARY")
1527        || starts_with_keyword("FOREIGN")
1528        || starts_with_keyword("UNIQUE")
1529        || starts_with_keyword("CHECK")
1530        || starts_with_keyword("CONSTRAINT")
1531        || starts_with_keyword("EXCLUDE")
1532        || starts_with_keyword("LIKE")
1533        || line_upper.starts_with(")")
1534        || line_upper.starts_with("(")
1535        || line.is_empty()
1536    {
1537        return None;
1538    }
1539
1540    extract_sql_column_ref(line.trim_start_matches('(').trim())
1541}
1542
1543fn extract_inline_create_columns(line: &str) -> Vec<String> {
1544    let Some(open_idx) = line.find('(') else {
1545        return Vec::new();
1546    };
1547    let Some(close_idx) = find_matching_sql_paren(line, open_idx) else {
1548        return Vec::new();
1549    };
1550    let body = &line[open_idx + 1..close_idx];
1551    split_sql_top_level_csv(body)
1552        .into_iter()
1553        .filter_map(extract_column_from_create)
1554        .collect()
1555}
1556
1557fn find_matching_sql_paren(raw: &str, open_idx: usize) -> Option<usize> {
1558    let mut depth = 0usize;
1559    let mut in_single = false;
1560    let mut in_double = false;
1561    let mut dollar_quote: Option<String> = None;
1562    let mut i = open_idx;
1563
1564    while i < raw.len() {
1565        if let Some(delim) = dollar_quote.as_deref() {
1566            if raw[i..].starts_with(delim) {
1567                i += delim.len();
1568                dollar_quote = None;
1569            } else {
1570                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1571            }
1572            continue;
1573        }
1574
1575        let ch = raw[i..].chars().next()?;
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 => {
1600                depth = depth.checked_sub(1)?;
1601                if depth == 0 {
1602                    return Some(i);
1603                }
1604            }
1605            _ => {}
1606        }
1607        i += ch.len_utf8();
1608    }
1609
1610    None
1611}
1612
1613fn split_sql_top_level_csv(raw: &str) -> Vec<&str> {
1614    let mut pieces = Vec::new();
1615    let mut start = 0usize;
1616    let mut depth = 0usize;
1617    let mut in_single = false;
1618    let mut in_double = false;
1619    let mut dollar_quote: Option<String> = None;
1620    let mut i = 0usize;
1621
1622    while i < raw.len() {
1623        if let Some(delim) = dollar_quote.as_deref() {
1624            if raw[i..].starts_with(delim) {
1625                i += delim.len();
1626                dollar_quote = None;
1627            } else {
1628                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1629            }
1630            continue;
1631        }
1632
1633        let Some(ch) = raw[i..].chars().next() else {
1634            break;
1635        };
1636        match ch {
1637            '\'' if !in_double => {
1638                if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1639                    i += 2;
1640                    continue;
1641                }
1642                in_single = !in_single;
1643            }
1644            '"' if !in_single => {
1645                if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1646                    i += 2;
1647                    continue;
1648                }
1649                in_double = !in_double;
1650            }
1651            '$' if !in_single && !in_double => {
1652                if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1653                    dollar_quote = Some(delim.to_string());
1654                    i += delim.len();
1655                    continue;
1656                }
1657            }
1658            '(' if !in_single && !in_double => depth += 1,
1659            ')' if !in_single && !in_double => depth = depth.saturating_sub(1),
1660            ',' if depth == 0 => {
1661                pieces.push(raw[start..i].trim());
1662                start = i + ch.len_utf8();
1663            }
1664            _ => {}
1665        }
1666        i += ch.len_utf8();
1667    }
1668
1669    pieces.push(raw[start..].trim());
1670    pieces
1671}
1672
1673fn split_sql_statements(raw: &str) -> Vec<String> {
1674    let mut statements = Vec::new();
1675    let mut start = 0usize;
1676    let mut in_single = false;
1677    let mut in_double = false;
1678    let mut dollar_quote: Option<String> = None;
1679    let mut i = 0usize;
1680
1681    while i < raw.len() {
1682        if let Some(delim) = dollar_quote.as_deref() {
1683            if raw[i..].starts_with(delim) {
1684                i += delim.len();
1685                dollar_quote = None;
1686            } else {
1687                i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1688            }
1689            continue;
1690        }
1691
1692        let Some(ch) = raw[i..].chars().next() else {
1693            break;
1694        };
1695        match ch {
1696            '\'' if !in_double => {
1697                if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1698                    i += 2;
1699                    continue;
1700                }
1701                in_single = !in_single;
1702            }
1703            '"' if !in_single => {
1704                if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1705                    i += 2;
1706                    continue;
1707                }
1708                in_double = !in_double;
1709            }
1710            '$' if !in_single && !in_double => {
1711                if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1712                    dollar_quote = Some(delim.to_string());
1713                    i += delim.len();
1714                    continue;
1715                }
1716            }
1717            ';' if !in_single && !in_double => {
1718                let statement = raw[start..i].trim();
1719                if !statement.is_empty() {
1720                    statements.push(statement.to_string());
1721                }
1722                start = i + ch.len_utf8();
1723            }
1724            _ => {}
1725        }
1726        i += ch.len_utf8();
1727    }
1728
1729    let tail = raw[start..].trim();
1730    if !tail.is_empty() {
1731        statements.push(tail.to_string());
1732    }
1733
1734    statements
1735}
1736
1737/// Extract table and columns from ALTER TABLE ... ADD [COLUMN] actions.
1738fn extract_alter_add_columns(line: &str) -> Vec<(String, String)> {
1739    let line_upper = line.to_uppercase();
1740    if !line_upper.starts_with("ALTER TABLE") {
1741        return Vec::new();
1742    }
1743    let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1744        return Vec::new();
1745    };
1746
1747    split_sql_top_level_csv(actions_part)
1748        .into_iter()
1749        .filter_map(|action| {
1750            extract_alter_add_column_action(action).map(|col| (table.clone(), col))
1751        })
1752        .collect()
1753}
1754
1755fn extract_alter_add_column_action(action: &str) -> Option<String> {
1756    let mut col_part = strip_sql_keyword(action, "ADD")?;
1757    col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1758    col_part = strip_sql_if_not_exists(col_part).unwrap_or(col_part);
1759
1760    let col_upper = col_part.trim_start().to_uppercase();
1761    if [
1762        "CONSTRAINT",
1763        "PRIMARY",
1764        "UNIQUE",
1765        "CHECK",
1766        "FOREIGN",
1767        "EXCLUDE",
1768    ]
1769    .iter()
1770    .any(|keyword| {
1771        col_upper.starts_with(keyword) && col_upper[keyword.len()..].starts_with([' ', '('])
1772    }) {
1773        return None;
1774    }
1775
1776    extract_sql_column_ref(col_part.trim())
1777}
1778
1779/// Extract table names from DROP TABLE statement
1780fn extract_drop_table_names(line: &str) -> Vec<String> {
1781    let line_upper = line.to_uppercase();
1782    let Some(rest) = line_upper.strip_prefix("DROP TABLE") else {
1783        return Vec::new();
1784    };
1785    let rest = rest.trim_start();
1786    let rest = if rest.starts_with("IF EXISTS") {
1787        match rest.strip_prefix("IF EXISTS") {
1788            Some(rest) => rest.trim_start(),
1789            None => return Vec::new(),
1790        }
1791    } else {
1792        rest
1793    };
1794
1795    split_sql_top_level_csv(&line[line.len() - rest.len()..])
1796        .into_iter()
1797        .filter_map(extract_sql_table_ref)
1798        .collect()
1799}
1800
1801/// Extract table and columns from ALTER TABLE ... DROP [COLUMN] actions.
1802fn extract_alter_drop_columns(line: &str) -> Vec<(String, String)> {
1803    let line_upper = line.to_uppercase();
1804    if !line_upper.starts_with("ALTER TABLE") {
1805        return Vec::new();
1806    }
1807    let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1808        return Vec::new();
1809    };
1810
1811    split_sql_top_level_csv(actions_part)
1812        .into_iter()
1813        .filter_map(|action| {
1814            extract_alter_drop_column_action(action).map(|col| (table.clone(), col))
1815        })
1816        .collect()
1817}
1818
1819fn extract_alter_drop_column_action(action: &str) -> Option<String> {
1820    let mut col_part = strip_sql_keyword(action, "DROP")?;
1821    col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1822    col_part = strip_sql_if_exists(col_part).unwrap_or(col_part);
1823
1824    let col_upper = col_part.trim_start().to_uppercase();
1825    if ["CONSTRAINT", "INDEX"].iter().any(|keyword| {
1826        col_upper.starts_with(keyword)
1827            && col_upper[keyword.len()..].starts_with(char::is_whitespace)
1828    }) {
1829        return None;
1830    }
1831
1832    extract_sql_column_ref(col_part.trim())
1833}
1834
1835fn extract_alter_rename_column(line: &str) -> Option<(String, String, String)> {
1836    let line_upper = line.to_uppercase();
1837    if !line_upper.starts_with("ALTER TABLE") {
1838        return None;
1839    }
1840    let (table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1841    let actions_upper = actions_part.to_uppercase();
1842    let (rename_pos, rename_len) = if let Some(pos) = actions_upper.find("RENAME COLUMN") {
1843        (pos, "RENAME COLUMN".len())
1844    } else {
1845        (actions_upper.find("RENAME ")?, "RENAME".len())
1846    };
1847    let to_pos = actions_upper[rename_pos + rename_len..].find(" TO ")? + rename_pos + rename_len;
1848
1849    let old_part = &actions_part[rename_pos + rename_len..to_pos];
1850    let new_part = &actions_part[to_pos + 4..];
1851    let old_col = extract_sql_column_ref(old_part.trim())?;
1852    let new_col = extract_sql_column_ref(new_part.trim())?;
1853
1854    Some((table, old_col, new_col))
1855}
1856
1857fn extract_alter_rename_table(line: &str) -> Option<(String, String)> {
1858    let line_upper = line.to_uppercase();
1859    if !line_upper.starts_with("ALTER TABLE") {
1860        return None;
1861    }
1862    let (old_table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1863    let actions_upper = actions_part.to_uppercase();
1864    let rename_pos = actions_upper.find("RENAME TO ")?;
1865
1866    let new_part = &actions_part[rename_pos + "RENAME TO ".len()..];
1867    let new_ref = extract_sql_table_ref(new_part.trim())?;
1868    let new_table = if new_ref.contains('.') {
1869        new_ref
1870    } else if let Some((schema, _)) = old_table.rsplit_once('.') {
1871        format!("{schema}.{new_ref}")
1872    } else {
1873        new_ref
1874    };
1875
1876    Some((old_table, new_table))
1877}
1878
1879fn extract_sql_table_ref(raw: &str) -> Option<String> {
1880    extract_sql_table_ref_with_tail(raw).map(|(name, _)| name)
1881}
1882
1883fn extract_sql_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1884    let mut rest = raw.trim_start();
1885    let mut parts = Vec::new();
1886
1887    loop {
1888        let (part, tail, _) = parse_sql_identifier_segment(rest)?;
1889        parts.push(part.to_ascii_lowercase());
1890        rest = tail.trim_start();
1891        if let Some(tail) = rest.strip_prefix('.') {
1892            rest = tail.trim_start();
1893        } else {
1894            break;
1895        }
1896    }
1897
1898    let name = parts.join(".");
1899    is_build_table_ref(&name).then_some((name, rest))
1900}
1901
1902fn extract_sql_column_ref(raw: &str) -> Option<String> {
1903    let (name, rest, quoted) = parse_sql_identifier_segment(raw)?;
1904    if rest.trim_start().starts_with('.') {
1905        return None;
1906    }
1907    let name = name.to_ascii_lowercase();
1908    if name.is_empty() || !is_build_identifier(&name) || (!quoted && name == "if") {
1909        None
1910    } else {
1911        Some(name)
1912    }
1913}
1914
1915fn parse_sql_identifier_segment(raw: &str) -> Option<(String, &str, bool)> {
1916    let rest = raw.trim_start();
1917    if let Some(quoted) = rest.strip_prefix('"') {
1918        let mut out = String::new();
1919        let mut chars = quoted.char_indices().peekable();
1920        while let Some((idx, ch)) = chars.next() {
1921            if ch == '"' {
1922                if chars.peek().is_some_and(|(_, next)| *next == '"') {
1923                    out.push('"');
1924                    chars.next();
1925                    continue;
1926                }
1927                let consumed = 1 + idx + ch.len_utf8();
1928                return Some((out, &rest[consumed..], true));
1929            }
1930            out.push(ch);
1931        }
1932        return None;
1933    }
1934
1935    let name: String = rest
1936        .chars()
1937        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1938        .collect();
1939    if name.is_empty() {
1940        return None;
1941    }
1942    let tail = &rest[name.len()..];
1943    Some((name, tail, false))
1944}
1945
1946fn extract_alter_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1947    let mut rest = raw.trim_start();
1948    let upper = rest.to_uppercase();
1949    if upper.starts_with("IF EXISTS")
1950        && rest
1951            .get("IF EXISTS".len()..)
1952            .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1953    {
1954        rest = rest.get("IF EXISTS".len()..)?.trim_start();
1955    }
1956    let upper = rest.to_uppercase();
1957    if upper.starts_with("ONLY")
1958        && rest
1959            .get("ONLY".len()..)
1960            .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1961    {
1962        rest = rest.get("ONLY".len()..)?.trim_start();
1963    }
1964    let (table, tail) = extract_sql_table_ref_with_tail(rest)?;
1965    Some((table, tail.trim_start()))
1966}
1967
1968impl TableSchema {
1969    /// Check if column exists
1970    pub fn has_column(&self, name: &str) -> bool {
1971        self.columns.contains_key(name)
1972    }
1973
1974    /// Get column type
1975    pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
1976        self.columns.get(name)
1977    }
1978
1979    /// Get the primary key column name for this table.
1980    ///
1981    /// Convention: returns `"id"` if it exists as a column.
1982    /// This is a single point of truth for PK resolution — when the schema
1983    /// parser is enhanced to track PK constraints, update this method.
1984    pub fn primary_key_column(&self) -> &str {
1985        if self.columns.contains_key("id") {
1986            "id"
1987        } else {
1988            // Fallback: look for `{singular_table_name}_id` pattern
1989            // e.g., table "users" → "user_id"
1990            let singular = self.name.trim_end_matches('s');
1991            let conventional = format!("{}_id", singular);
1992            if self.columns.contains_key(&conventional) {
1993                // Leak into 'static to satisfy lifetime — this is called rarely
1994                // and the string is small. Alternatively, return String.
1995                return "id"; // Safe default — schema has no "id" but this avoids lifetime issues
1996            }
1997            "id" // Universal fallback
1998        }
1999    }
2000}
2001
2002#[cfg(test)]
2003mod comment_tests {
2004    use super::{ColumnType, Schema, strip_schema_comments, strip_sql_line_comments};
2005
2006    #[test]
2007    fn schema_comment_stripping_ignores_markers_inside_quotes() {
2008        assert_eq!(
2009            strip_schema_comments(r#"status TEXT default 'draft--internal#tag' # comment"#),
2010            r#"status TEXT default 'draft--internal#tag'"#
2011        );
2012        assert_eq!(
2013            strip_schema_comments(r#"status TEXT default "draft--internal#tag" -- comment"#),
2014            r#"status TEXT default "draft--internal#tag""#
2015        );
2016    }
2017
2018    #[test]
2019    fn sql_comment_stripping_ignores_double_dash_inside_strings() {
2020        assert_eq!(
2021            strip_sql_line_comments("CREATE TABLE logs (message text DEFAULT 'a--b'); -- comment"),
2022            "CREATE TABLE logs (message text DEFAULT 'a--b');"
2023        );
2024        assert_eq!(
2025            strip_sql_line_comments("CREATE TABLE tags (name text DEFAULT '#not-comment');"),
2026            "CREATE TABLE tags (name text DEFAULT '#not-comment');"
2027        );
2028    }
2029
2030    #[test]
2031    fn sql_migration_paren_depth_ignores_string_literals() {
2032        let mut schema = Schema::default();
2033        schema.parse_sql_migration(
2034            r#"
2035CREATE TABLE logs (
2036  message text DEFAULT ')',
2037  tag text DEFAULT '(',
2038  level text
2039);
2040"#,
2041        );
2042
2043        let logs = schema.table("logs").expect("logs table should parse");
2044        assert!(logs.has_column("message"));
2045        assert!(logs.has_column("tag"));
2046        assert!(logs.has_column("level"));
2047    }
2048
2049    #[test]
2050    fn schema_parse_accepts_pulled_rls_directives() {
2051        let schema = Schema::parse(
2052            r#"
2053table agents {
2054  id UUID
2055  tenant_id UUID
2056  enable_rls
2057  force_rls
2058}
2059"#,
2060        )
2061        .expect("pulled schema RLS directives should parse");
2062
2063        let agents = schema.table("agents").expect("agents table should parse");
2064        assert!(agents.has_column("id"));
2065        assert!(agents.rls_enabled);
2066        assert!(!agents.has_column("enable_rls"));
2067        assert!(!agents.has_column("force_rls"));
2068    }
2069
2070    #[test]
2071    fn schema_parse_accepts_multi_word_column_types() {
2072        let schema = Schema::parse(
2073            r#"
2074table car_fullday_reseller_pricing {
2075  percentage_markup DOUBLE PRECISION
2076  starts_at TIMESTAMP WITH TIME ZONE
2077}
2078"#,
2079        )
2080        .expect("pulled schema multi-word types should parse");
2081
2082        let pricing = schema
2083            .table("car_fullday_reseller_pricing")
2084            .expect("pricing table should parse");
2085        assert_eq!(
2086            pricing.column_type("percentage_markup"),
2087            Some(&ColumnType::Float)
2088        );
2089        assert_eq!(
2090            pricing.column_type("starts_at"),
2091            Some(&ColumnType::Timestamptz)
2092        );
2093    }
2094
2095    #[test]
2096    fn sql_migration_ignores_multiline_block_comments() {
2097        let mut schema = Schema::default();
2098        schema.parse_sql_migration(
2099            r#"
2100CREATE TABLE users (
2101  id uuid
2102);
2103
2104/*
2105ALTER TABLE users ADD COLUMN hidden text;
2106CREATE TABLE hidden_table (
2107  id uuid
2108);
2109*/
2110"#,
2111        );
2112
2113        let users = schema.table("users").expect("users table should parse");
2114        assert!(users.has_column("id"));
2115        assert!(!users.has_column("hidden"));
2116        assert!(!schema.has_table("hidden_table"));
2117    }
2118
2119    #[test]
2120    fn sql_migration_preserves_schema_qualified_table_names() {
2121        let mut schema = Schema::default();
2122        schema.parse_sql_migration(
2123            r#"
2124CREATE TABLE app.users (
2125  id uuid
2126);
2127
2128ALTER TABLE app.users ADD COLUMN email text;
2129"#,
2130        );
2131
2132        assert!(!schema.has_table("app"));
2133        let users = schema
2134            .table("app.users")
2135            .expect("schema-qualified table should parse");
2136        assert!(users.has_column("id"));
2137        assert!(users.has_column("email"));
2138    }
2139
2140    #[test]
2141    fn sql_migration_extracts_inline_create_table_columns() {
2142        let mut schema = Schema::default();
2143        schema.parse_sql_migration(
2144            "CREATE TABLE users (id uuid, email text DEFAULT 'a,b', CHECK (length(email) > 3));",
2145        );
2146
2147        let users = schema.table("users").expect("users table should parse");
2148        assert!(users.has_column("id"));
2149        assert!(users.has_column("email"));
2150        assert!(!users.has_column("check"));
2151    }
2152
2153    #[test]
2154    fn sql_migration_drops_multiple_tables() {
2155        let mut schema = Schema::default();
2156        schema.parse_sql_migration(
2157            r#"
2158CREATE TABLE app.users (id uuid);
2159CREATE TABLE app.posts (id uuid);
2160DROP TABLE IF EXISTS app.users, app.posts CASCADE;
2161"#,
2162        );
2163
2164        assert!(!schema.has_table("app.users"));
2165        assert!(!schema.has_table("app.posts"));
2166    }
2167
2168    #[test]
2169    fn sql_migration_ignores_create_table_non_column_clauses() {
2170        let mut schema = Schema::default();
2171        schema.parse_sql_migration(
2172            r#"
2173CREATE TABLE bookings (
2174  id uuid,
2175  EXCLUDE USING gist (room WITH =),
2176  LIKE booking_template INCLUDING ALL
2177);
2178"#,
2179        );
2180
2181        let bookings = schema
2182            .table("bookings")
2183            .expect("bookings table should parse");
2184        assert!(bookings.has_column("id"));
2185        assert!(!bookings.has_column("exclude"));
2186        assert!(!bookings.has_column("like"));
2187    }
2188
2189    #[test]
2190    fn sql_migration_ignores_alter_add_constraints() {
2191        let mut schema = Schema::default();
2192        schema.parse_sql_migration(
2193            r#"
2194CREATE TABLE users (id uuid, email text);
2195ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
2196ALTER TABLE users ADD PRIMARY KEY (id);
2197"#,
2198        );
2199
2200        let users = schema.table("users").expect("users table should parse");
2201        assert!(users.has_column("id"));
2202        assert!(users.has_column("email"));
2203        assert!(!users.has_column("constraint"));
2204        assert!(!users.has_column("primary"));
2205    }
2206
2207    #[test]
2208    fn sql_migration_handles_alter_table_modifiers() {
2209        let mut schema = Schema::default();
2210        schema.parse_sql_migration(
2211            r#"
2212CREATE TABLE users (id uuid);
2213ALTER TABLE ONLY users ADD COLUMN email text;
2214ALTER TABLE IF EXISTS users DROP COLUMN id;
2215"#,
2216        );
2217
2218        assert!(!schema.has_table("only"));
2219        assert!(!schema.has_table("if"));
2220        let users = schema.table("users").expect("users table should parse");
2221        assert!(!users.has_column("id"));
2222        assert!(users.has_column("email"));
2223    }
2224
2225    #[test]
2226    fn sql_migration_handles_drop_column_if_exists() {
2227        let mut schema = Schema::default();
2228        schema.parse_sql_migration(
2229            r#"
2230CREATE TABLE users (id uuid, old_email text, old_name text);
2231ALTER TABLE users DROP COLUMN IF EXISTS old_email;
2232ALTER TABLE users DROP IF EXISTS old_name;
2233"#,
2234        );
2235
2236        let users = schema.table("users").expect("users table should parse");
2237        assert!(users.has_column("id"));
2238        assert!(!users.has_column("old_email"));
2239        assert!(!users.has_column("old_name"));
2240        assert!(!users.has_column("if"));
2241    }
2242
2243    #[test]
2244    fn sql_migration_handles_quoted_table_and_column_identifiers() {
2245        let mut schema = Schema::default();
2246        schema.parse_sql_migration(
2247            r#"
2248CREATE TABLE "app"."order" ("id" uuid, "select" text);
2249ALTER TABLE "app"."order" ADD COLUMN "from" text;
2250ALTER TABLE "app"."order" DROP COLUMN "select";
2251"#,
2252        );
2253
2254        let orders = schema
2255            .table("app.order")
2256            .expect("quoted schema-qualified table should parse");
2257        assert!(orders.has_column("id"));
2258        assert!(orders.has_column("from"));
2259        assert!(!orders.has_column("select"));
2260    }
2261
2262    #[test]
2263    fn sql_migration_ignores_dollar_quoted_default_syntax() {
2264        let mut schema = Schema::default();
2265        schema.parse_sql_migration(
2266            r#"
2267CREATE TABLE logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2268"#,
2269        );
2270
2271        let logs = schema.table("logs").expect("logs table should parse");
2272        assert!(logs.has_column("id"));
2273        assert!(logs.has_column("body"));
2274        assert!(logs.has_column("tag"));
2275        assert!(!logs.has_column("b"));
2276        assert!(!logs.has_column("not"));
2277    }
2278
2279    #[test]
2280    fn sql_migration_ignores_multiline_dollar_quoted_bodies() {
2281        let mut schema = Schema::default();
2282        schema.parse_sql_migration(
2283            r#"
2284CREATE TABLE users (id uuid);
2285CREATE FUNCTION rebuild_hidden() RETURNS void AS $$
2286BEGIN
2287  CREATE TABLE hidden_from_function (id uuid);
2288END;
2289$$ LANGUAGE plpgsql;
2290"#,
2291        );
2292
2293        assert!(schema.has_table("users"));
2294        assert!(!schema.has_table("hidden_from_function"));
2295    }
2296
2297    #[test]
2298    fn sql_migration_handles_unlogged_create_tables() {
2299        let mut schema = Schema::default();
2300        schema.parse_sql_migration(
2301            r#"
2302CREATE UNLOGGED TABLE IF NOT EXISTS jobs (id uuid, status text);
2303CREATE TEMP TABLE scratch_jobs (id uuid);
2304"#,
2305        );
2306
2307        let jobs = schema.table("jobs").expect("unlogged table should parse");
2308        assert!(jobs.has_column("id"));
2309        assert!(jobs.has_column("status"));
2310        assert!(!schema.has_table("scratch_jobs"));
2311    }
2312
2313    #[test]
2314    fn sql_migration_tracks_column_renames() {
2315        let mut schema = Schema::default();
2316        schema.parse_sql_migration(
2317            r#"
2318CREATE TABLE users (id uuid, old_email text);
2319ALTER TABLE users RENAME COLUMN old_email TO email;
2320"#,
2321        );
2322
2323        let users = schema.table("users").expect("users table should parse");
2324        assert!(users.has_column("id"));
2325        assert!(users.has_column("email"));
2326        assert!(!users.has_column("old_email"));
2327    }
2328
2329    #[test]
2330    fn sql_migration_tracks_table_renames() {
2331        let mut schema = Schema::default();
2332        schema.parse_sql_migration(
2333            r#"
2334CREATE TABLE app.users (id uuid, email text);
2335ALTER TABLE app.users RENAME TO customers;
2336"#,
2337        );
2338
2339        assert!(!schema.has_table("app.users"));
2340        let customers = schema
2341            .table("app.customers")
2342            .expect("schema-qualified table rename should parse");
2343        assert!(customers.has_column("id"));
2344        assert!(customers.has_column("email"));
2345    }
2346
2347    #[test]
2348    fn sql_migration_handles_add_if_not_exists_without_column_keyword() {
2349        let mut schema = Schema::default();
2350        schema.parse_sql_migration(
2351            r#"
2352CREATE TABLE users (id uuid);
2353ALTER TABLE users ADD IF NOT EXISTS email text;
2354"#,
2355        );
2356
2357        let users = schema.table("users").expect("users table should parse");
2358        assert!(users.has_column("id"));
2359        assert!(users.has_column("email"));
2360        assert!(!users.has_column("if"));
2361    }
2362
2363    #[test]
2364    fn sql_migration_tracks_column_renames_without_column_keyword() {
2365        let mut schema = Schema::default();
2366        schema.parse_sql_migration(
2367            r#"
2368CREATE TABLE users (id uuid, old_email text);
2369ALTER TABLE users RENAME old_email TO email;
2370"#,
2371        );
2372
2373        let users = schema.table("users").expect("users table should parse");
2374        assert!(users.has_column("email"));
2375        assert!(!users.has_column("old_email"));
2376    }
2377
2378    #[test]
2379    fn sql_migration_does_not_treat_create_table_as_select_as_column_block() {
2380        let mut schema = Schema::default();
2381        schema.parse_sql_migration(
2382            r#"
2383CREATE TABLE reports AS SELECT id FROM users;
2384ALTER TABLE reports ADD COLUMN status text;
2385"#,
2386        );
2387
2388        let reports = schema.table("reports").expect("reports table should parse");
2389        assert!(reports.has_column("status"));
2390        assert!(!reports.has_column("alter"));
2391    }
2392
2393    #[test]
2394    fn sql_migration_handles_multiple_alter_add_actions() {
2395        let mut schema = Schema::default();
2396        schema.parse_sql_migration(
2397            r#"
2398CREATE TABLE users (id uuid);
2399ALTER TABLE users ADD COLUMN email text, ADD IF NOT EXISTS name text;
2400"#,
2401        );
2402
2403        let users = schema.table("users").expect("users table should parse");
2404        assert!(users.has_column("email"));
2405        assert!(users.has_column("name"));
2406    }
2407
2408    #[test]
2409    fn sql_migration_handles_multiple_alter_drop_actions() {
2410        let mut schema = Schema::default();
2411        schema.parse_sql_migration(
2412            r#"
2413CREATE TABLE users (id uuid, old_email text, old_name text);
2414ALTER TABLE users DROP COLUMN old_email, DROP IF EXISTS old_name;
2415"#,
2416        );
2417
2418        let users = schema.table("users").expect("users table should parse");
2419        assert!(users.has_column("id"));
2420        assert!(!users.has_column("old_email"));
2421        assert!(!users.has_column("old_name"));
2422    }
2423
2424    #[test]
2425    fn sql_migration_handles_multiline_mixed_alter_actions() {
2426        let mut schema = Schema::default();
2427        schema.parse_sql_migration(
2428            r#"
2429CREATE TABLE users (id uuid, old_email text, old_name text);
2430ALTER TABLE users
2431  ADD COLUMN email text,
2432  DROP COLUMN old_email,
2433  RENAME COLUMN old_name TO legacy_name;
2434"#,
2435        );
2436
2437        let users = schema.table("users").expect("users table should parse");
2438        assert!(users.has_column("id"));
2439        assert!(users.has_column("email"));
2440        assert!(users.has_column("legacy_name"));
2441        assert!(!users.has_column("old_email"));
2442        assert!(!users.has_column("old_name"));
2443    }
2444
2445    #[test]
2446    fn sql_migration_handles_drop_then_recreate_order() {
2447        let mut schema = Schema::default();
2448        schema.parse_sql_migration(
2449            r#"
2450CREATE TABLE users (stale text);
2451DROP TABLE users;
2452CREATE TABLE users (id uuid, email text);
2453"#,
2454        );
2455
2456        let users = schema
2457            .table("users")
2458            .expect("recreated table should remain in schema");
2459        assert!(users.has_column("id"));
2460        assert!(users.has_column("email"));
2461        assert!(!users.has_column("stale"));
2462    }
2463
2464    #[test]
2465    fn sql_migration_allows_alter_add_columns_with_constraint_prefixes() {
2466        let mut schema = Schema::default();
2467        schema.parse_sql_migration(
2468            r#"
2469CREATE TABLE users (id uuid);
2470ALTER TABLE users ADD COLUMN primary_contact text, ADD check_status text;
2471"#,
2472        );
2473
2474        let users = schema.table("users").expect("users table should parse");
2475        assert!(users.has_column("primary_contact"));
2476        assert!(users.has_column("check_status"));
2477    }
2478
2479    #[test]
2480    fn sql_migration_handles_create_table_paren_on_next_line() {
2481        let mut schema = Schema::default();
2482        schema.parse_sql_migration(
2483            r#"
2484CREATE TABLE users
2485(
2486  id uuid,
2487  email text
2488);
2489"#,
2490        );
2491
2492        let users = schema.table("users").expect("users table should parse");
2493        assert!(users.has_column("id"));
2494        assert!(users.has_column("email"));
2495    }
2496
2497    #[test]
2498    fn sql_migration_does_not_treat_alter_column_drop_as_column_drop() {
2499        let mut schema = Schema::default();
2500        schema.parse_sql_migration(
2501            r#"
2502CREATE TABLE users (id uuid, email text, not text);
2503ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
2504"#,
2505        );
2506
2507        let users = schema.table("users").expect("users table should parse");
2508        assert!(users.has_column("email"));
2509        assert!(users.has_column("not"));
2510    }
2511
2512    #[test]
2513    fn sql_migration_chaos_mixed_postgres_syntax() {
2514        let mut schema = Schema::default();
2515        schema.parse_sql_migration(
2516            r#"
2517CREATE SCHEMA app;
2518CREATE UNLOGGED TABLE IF NOT EXISTS "app"."users"
2519(
2520  id uuid,
2521  old_email text,
2522  old_name text,
2523  "select" text,
2524  "not" text
2525);
2526CREATE TEMP TABLE scratch_jobs (id uuid);
2527ALTER TABLE ONLY "app"."users" ADD COLUMN primary_contact text, ADD check_status text;
2528ALTER TABLE "app"."users" ADD IF NOT EXISTS guarded text;
2529ALTER TABLE "app"."users" DROP COLUMN "select", DROP IF EXISTS guarded, DROP COLUMN IF EXISTS old_name;
2530ALTER TABLE "app"."users" RENAME old_email TO email;
2531ALTER TABLE "app"."users" ALTER COLUMN email DROP NOT NULL;
2532ALTER TABLE "app"."users" RENAME TO customers;
2533
2534CREATE TABLE app.logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2535CREATE FUNCTION app.rebuild_hidden() RETURNS void AS $$
2536BEGIN
2537  CREATE TABLE hidden_from_function (id uuid);
2538END;
2539$$ LANGUAGE plpgsql;
2540CREATE TABLE app.reports AS SELECT id FROM app.customers;
2541ALTER TABLE app.reports ADD COLUMN status text;
2542"#,
2543        );
2544
2545        assert!(!schema.has_table("scratch_jobs"));
2546        assert!(!schema.has_table("app.users"));
2547        assert!(!schema.has_table("hidden_from_function"));
2548
2549        let customers = schema
2550            .table("app.customers")
2551            .expect("renamed schema-qualified table should parse");
2552        assert!(customers.has_column("id"));
2553        assert!(customers.has_column("email"));
2554        assert!(customers.has_column("not"));
2555        assert!(customers.has_column("primary_contact"));
2556        assert!(customers.has_column("check_status"));
2557        assert!(!customers.has_column("old_email"));
2558        assert!(!customers.has_column("old_name"));
2559        assert!(!customers.has_column("select"));
2560        assert!(!customers.has_column("guarded"));
2561
2562        let logs = schema.table("app.logs").expect("logs table should parse");
2563        assert!(logs.has_column("id"));
2564        assert!(logs.has_column("body"));
2565        assert!(logs.has_column("tag"));
2566        assert!(!logs.has_column("b"));
2567
2568        let reports = schema
2569            .table("app.reports")
2570            .expect("ctas table should parse");
2571        assert!(reports.has_column("status"));
2572        assert!(!reports.has_column("alter"));
2573    }
2574}