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