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