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