Skip to main content

qail_core/migrate/
parser.rs

1//! QAIL Schema Parser
2//!
3//! Parses .qail text format into Schema AST.
4//!
5//! ## Grammar
6//! ```text
7//! schema = { table_def | index_def | migration_hint }*
8//!
9//! table_def = "table" IDENT "{" column_def* "}"
10//! column_def = IDENT TYPE constraint*
11//! constraint = "primary_key" | "not_null" | "nullable" | "unique" | "default" VALUE
12//!
13//! index_def = ["unique"] "index" IDENT "on" IDENT "(" IDENT+ ")"
14//!
15//! migration_hint = "rename" PATH "->" PATH
16//!                | "transform" EXPR "->" PATH
17//!                | "drop" PATH ["confirm"]
18//! ```
19
20use super::policy::{PolicyPermissiveness, PolicyTarget, RlsPolicy};
21use super::schema::{
22    CheckComparisonOp, CheckConstraint, CheckExpr, Column, Comment, Deferrable, EnumType,
23    Extension, FkAction, Generated, Grant, Index, IndexMethod, MigrationHint,
24    MultiColumnForeignKey, Privilege, ResourceDef, ResourceKind, Schema, SchemaFunctionDef,
25    SchemaTriggerDef, Sequence, Table, ViewDef,
26};
27use super::types::ColumnType;
28use crate::ast::Expr;
29use std::collections::{HashMap, HashSet};
30
31/// Parse a .qail file into a Schema.
32pub fn parse_qail(input: &str) -> Result<Schema, String> {
33    let mut schema = Schema::new();
34    let mut lines = input.lines().peekable();
35
36    while let Some(line) = lines.next() {
37        let line = line.trim();
38
39        // Skip empty lines, # comments, -- comments, and version directives
40        if is_blank_or_qail_comment(line) {
41            continue;
42        }
43
44        if line.starts_with("table ") {
45            let (table, consumed) = parse_table(line, &mut lines, &schema.enums)?;
46            if schema.tables.contains_key(&table.name) {
47                return Err(format!("duplicate table declaration '{}'", table.name));
48            }
49            schema.add_table(table);
50            // consumed lines already processed
51            let _ = consumed;
52        } else if line.starts_with("unique index ") || line.starts_with("index ") {
53            let index = parse_index(line)?;
54            if schema
55                .indexes
56                .iter()
57                .any(|existing| existing.name == index.name)
58            {
59                return Err(format!("duplicate index declaration '{}'", index.name));
60            }
61            schema.add_index(index);
62        } else if line.starts_with("extension ") {
63            let ext = parse_extension(line)?;
64            schema.add_extension(ext);
65        } else if line.starts_with("comment ") {
66            let comment = parse_comment(line)?;
67            schema.add_comment(comment);
68        } else if line.starts_with("sequence ") {
69            let seq = parse_sequence(line, &mut lines)?;
70            if schema
71                .sequences
72                .iter()
73                .any(|existing| existing.name == seq.name)
74            {
75                return Err(format!("duplicate sequence declaration '{}'", seq.name));
76            }
77            schema.add_sequence(seq);
78        } else if line.starts_with("enum ") {
79            let enum_type = parse_enum(line, &mut lines)?;
80            if schema
81                .enums
82                .iter()
83                .any(|existing| existing.name == enum_type.name)
84            {
85                return Err(format!("duplicate enum declaration '{}'", enum_type.name));
86            }
87            schema.add_enum(enum_type);
88        } else if line.starts_with("view ") || line.starts_with("materialized view ") {
89            let view = parse_view(line, &mut lines)?;
90            if schema
91                .views
92                .iter()
93                .any(|existing| existing.name == view.name)
94            {
95                return Err(format!("duplicate view declaration '{}'", view.name));
96            }
97            schema.add_view(view);
98        } else if line.starts_with("function ") {
99            let func = parse_function(line, &mut lines)?;
100            if schema
101                .functions
102                .iter()
103                .any(|existing| existing.name == func.name && existing.args == func.args)
104            {
105                return Err(format!(
106                    "duplicate function declaration '{}({})'",
107                    func.name,
108                    func.args.join(", ")
109                ));
110            }
111            schema.add_function(func);
112        } else if line.starts_with("trigger ") {
113            let trigger = parse_trigger(line)?;
114            if schema
115                .triggers
116                .iter()
117                .any(|existing| existing.name == trigger.name && existing.table == trigger.table)
118            {
119                return Err(format!(
120                    "duplicate trigger declaration '{} on {}'",
121                    trigger.name, trigger.table
122                ));
123            }
124            schema.add_trigger(trigger);
125        } else if line.starts_with("grant ") || line.starts_with("revoke ") {
126            let grant = parse_grant(line)?;
127            schema.add_grant(grant);
128        } else if line.starts_with("rename ") {
129            let hint = parse_rename(line)?;
130            schema.add_hint(hint);
131        } else if line.starts_with("transform ") {
132            let hint = parse_transform(line)?;
133            schema.add_hint(hint);
134        } else if line.starts_with("drop ") {
135            let hint = parse_drop(line)?;
136            schema.add_hint(hint);
137        } else if line.starts_with("bucket ") {
138            let res = parse_resource(line, &mut lines, ResourceKind::Bucket)?;
139            if schema
140                .resources
141                .iter()
142                .any(|existing| existing.name == res.name)
143            {
144                return Err(format!("duplicate resource declaration '{}'", res.name));
145            }
146            schema.add_resource(res);
147        } else if line.starts_with("queue ") {
148            let res = parse_resource(line, &mut lines, ResourceKind::Queue)?;
149            if schema
150                .resources
151                .iter()
152                .any(|existing| existing.name == res.name)
153            {
154                return Err(format!("duplicate resource declaration '{}'", res.name));
155            }
156            schema.add_resource(res);
157        } else if line.starts_with("topic ") {
158            let res = parse_resource(line, &mut lines, ResourceKind::Topic)?;
159            if schema
160                .resources
161                .iter()
162                .any(|existing| existing.name == res.name)
163            {
164                return Err(format!("duplicate resource declaration '{}'", res.name));
165            }
166            schema.add_resource(res);
167        } else if line.starts_with("policy ") {
168            let policy = parse_policy(line, &mut lines)?;
169            if schema
170                .policies
171                .iter()
172                .any(|existing| existing.name == policy.name && existing.table == policy.table)
173            {
174                return Err(format!(
175                    "duplicate policy declaration '{} on {}'",
176                    policy.name, policy.table
177                ));
178            }
179            schema.add_policy(policy);
180        } else {
181            return Err(format!("Unknown statement: {}", line));
182        }
183    }
184
185    Ok(schema)
186}
187
188/// Parse schema from a file or modular schema directory.
189///
190/// `path` may be:
191/// - a single `.qail` file
192/// - a directory containing one or more `.qail` modules
193pub fn parse_qail_file(path: &str) -> Result<Schema, String> {
194    let content = crate::schema_source::read_qail_schema_source(path)?;
195    parse_qail(&content)
196}
197
198fn is_blank_or_qail_comment(line: &str) -> bool {
199    let trimmed = line.trim();
200    trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("--")
201}
202
203/// Parse a table definition with columns.
204fn parse_table<'a, I>(
205    first_line: &str,
206    lines: &mut std::iter::Peekable<I>,
207    enum_types: &[EnumType],
208) -> Result<(Table, usize), String>
209where
210    I: Iterator<Item = &'a str>,
211{
212    let rest = first_line
213        .strip_prefix("table ")
214        .ok_or("Expected 'table' prefix")?;
215    let (name_part, after_open) = rest
216        .split_once('{')
217        .ok_or_else(|| "table declaration requires an opening '{'".to_string())?;
218    if !after_open.trim().is_empty() {
219        return Err("trailing content after table opening brace".to_string());
220    }
221    let name = name_part.trim().to_string();
222
223    if name.is_empty() {
224        return Err("Table name required".to_string());
225    }
226    // The header is `table <name> {` and nothing else. Anything after the
227    // name would otherwise be swallowed INTO the name and reach DDL as
228    // `CREATE TABLE listings owner=seller_id (...)`. Table attributes
229    // (`enable_rls`, `force_rls`, `owner <col>`) live inside the block.
230    if !name
231        .chars()
232        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '"')
233    {
234        return Err(format!(
235            "Invalid table header '{}': attributes belong inside the block (e.g. `owner seller_id`)",
236            name
237        ));
238    }
239
240    let mut table = Table::new(&name);
241    let mut consumed = 0;
242    let mut found_closing_brace = false;
243    let mut seen_columns = HashSet::new();
244
245    for line in lines.by_ref() {
246        consumed += 1;
247        let line = line.trim();
248
249        if let Some(after_close) = line.strip_prefix('}') {
250            if !after_close.trim().is_empty() {
251                return Err("trailing content after table closing brace".to_string());
252            }
253            found_closing_brace = true;
254            break;
255        }
256
257        if is_blank_or_qail_comment(line) {
258            continue;
259        }
260
261        // Table-level multi-column foreign key
262        if line == "foreign_key"
263            || line.starts_with("foreign_key ")
264            || line.starts_with("foreign_key(")
265        {
266            let fk = parse_multi_column_fk(line)?;
267            table.multi_column_fks.push(fk);
268            continue;
269        }
270
271        // Table-level RLS directives
272        if line == "enable_rls" {
273            table.enable_rls = true;
274            continue;
275        }
276        if line == "force_rls" {
277            table.force_rls = true;
278            continue;
279        }
280        if let Some(owner) = line.strip_prefix("owner ") {
281            let owner = owner.trim();
282            if owner.is_empty() || !owner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
283                return Err(format!(
284                    "Invalid owner column '{}' for table '{}'",
285                    owner, name
286                ));
287            }
288            if table.owner_column.is_some() {
289                return Err(format!("Duplicate owner declaration for table '{}'", name));
290            }
291            table.owner_column = Some(owner.to_string());
292            continue;
293        }
294
295        let col = parse_column(line, enum_types)?;
296        if !seen_columns.insert(col.name.clone()) {
297            return Err(format!(
298                "duplicate column '{}' in table '{}'",
299                col.name, name
300            ));
301        }
302        table.columns.push(col);
303    }
304
305    if !found_closing_brace {
306        return Err(format!("Unclosed table definition '{}'", name));
307    }
308    if let Some(owner) = table.owner_column.as_deref()
309        && !table.columns.iter().any(|c| c.name == owner)
310    {
311        return Err(format!(
312            "Owner column '{}' is not declared in table '{}'",
313            owner, name
314        ));
315    }
316
317    Ok((table, consumed))
318}
319
320/// Parse a column definition.
321fn parse_column(line: &str, enum_types: &[EnumType]) -> Result<Column, String> {
322    let parts: Vec<&str> = line.split_whitespace().collect();
323
324    if parts.len() < 2 {
325        return Err(format!("Invalid column: {}", line));
326    }
327
328    let name = parts[0].to_string();
329    let (data_type, type_end) = parse_column_type_prefix(&parts, enum_types, &name)?;
330
331    let mut col = Column::new(&name, data_type);
332
333    let mut i = type_end;
334    let mut seen_primary_key = false;
335    let mut nullability_option: Option<&str> = None;
336    let mut seen_default = false;
337    let mut seen_unique = false;
338    let mut seen_generated = false;
339    while i < parts.len() {
340        match parts[i] {
341            "primary_key" => {
342                if seen_primary_key {
343                    return Err(format!(
344                        "duplicate primary_key option for column '{}'",
345                        name
346                    ));
347                }
348                if nullability_option == Some("nullable") {
349                    return Err(format!(
350                        "primary_key conflicts with nullable for column '{}'",
351                        name
352                    ));
353                }
354                seen_primary_key = true;
355                col = col
356                    .try_primary_key()
357                    .map_err(|e| format!("{} (column '{}')", e, name))?;
358            }
359            "not_null" => {
360                if let Some(existing) = nullability_option {
361                    return Err(format!(
362                        "conflicting nullability options '{}' and 'not_null' for column '{}'",
363                        existing, name
364                    ));
365                }
366                nullability_option = Some("not_null");
367                col.nullable = false;
368            }
369            "nullable" => {
370                if seen_primary_key {
371                    return Err(format!(
372                        "nullable conflicts with primary_key for column '{}'",
373                        name
374                    ));
375                }
376                if let Some(existing) = nullability_option {
377                    return Err(format!(
378                        "conflicting nullability options '{}' and 'nullable' for column '{}'",
379                        existing, name
380                    ));
381                }
382                nullability_option = Some("nullable");
383                col.nullable = true;
384            }
385            "unique" => {
386                if seen_unique {
387                    return Err(format!("duplicate unique option for column '{}'", name));
388                }
389                seen_unique = true;
390                col = col
391                    .try_unique()
392                    .map_err(|e| format!("{} (column '{}')", e, name))?;
393            }
394            "default" if i + 1 < parts.len() => {
395                if seen_default {
396                    return Err(format!("duplicate default option for column '{}'", name));
397                }
398                seen_default = true;
399                let mut default_parts = Vec::new();
400                i += 1;
401                default_parts.push(parts[i]);
402                while i + 1 < parts.len() && !is_column_constraint_keyword(parts[i + 1]) {
403                    i += 1;
404                    default_parts.push(parts[i]);
405                }
406                col.default = Some(default_parts.join(" "));
407            }
408            "default" => {
409                return Err(format!("default requires a value for column '{}'", name));
410            }
411            "generated_identity" => {
412                if seen_generated {
413                    return Err(format!("duplicate generated option for column '{}'", name));
414                }
415                seen_generated = true;
416                col.generated = Some(Generated::AlwaysIdentity);
417            }
418            "generated_by_default_identity" => {
419                if seen_generated {
420                    return Err(format!("duplicate generated option for column '{}'", name));
421                }
422                seen_generated = true;
423                col.generated = Some(Generated::ByDefaultIdentity);
424            }
425            s if s.starts_with("generated_stored(") => {
426                if seen_generated {
427                    return Err(format!("duplicate generated option for column '{}'", name));
428                }
429                seen_generated = true;
430                let mut generated_str = s.to_string();
431                let mut quote = None;
432                let mut depth = paren_delta_ignoring_quotes(s, &mut quote);
433
434                while (depth > 0 || quote.is_some()) && i + 1 < parts.len() {
435                    i += 1;
436                    generated_str.push(' ');
437                    generated_str.push_str(parts[i]);
438                    depth += paren_delta_ignoring_quotes(parts[i], &mut quote);
439                }
440                if quote.is_some() {
441                    return Err(format!(
442                        "unterminated quote in generated_stored expression for column '{}'",
443                        name
444                    ));
445                }
446                if depth != 0 {
447                    return Err(format!(
448                        "unclosed generated_stored expression for column '{}'",
449                        name
450                    ));
451                }
452
453                let inner = generated_str
454                    .strip_prefix("generated_stored(")
455                    .and_then(|s| s.strip_suffix(')'))
456                    .ok_or_else(|| {
457                        format!("invalid generated_stored expression for column '{}'", name)
458                    })?
459                    .trim();
460                if inner.is_empty() {
461                    return Err(format!(
462                        "generated_stored expression is empty for column '{}'",
463                        name
464                    ));
465                }
466                col.generated = Some(Generated::AlwaysStored(inner.to_string()));
467            }
468            "references" => {
469                let fk_str = if i + 1 < parts.len() {
470                    i += 1;
471                    parts[i]
472                } else {
473                    return Err(format!(
474                        "foreign key reference target is required for column '{}'",
475                        name
476                    ));
477                };
478
479                let (table, column) = parse_fk_reference_target(fk_str)?;
480                col = col.references(table, column);
481                col = apply_fk_action_options(col, &parts, &mut i)?;
482            }
483            s if s.starts_with("references(") => {
484                let inner = s
485                    .strip_prefix("references(")
486                    .and_then(|s| s.strip_suffix(')'))
487                    .ok_or_else(|| format!("invalid foreign key reference target: {}", s))?;
488                let (table, column) = inner
489                    .split_once('.')
490                    .ok_or_else(|| format!("invalid foreign key reference target: {}", s))?;
491                if !is_native_table_ref(table.trim()) || !is_native_identifier(column.trim()) {
492                    return Err(format!("invalid foreign key reference target: {}", s));
493                }
494                col = col.references(table.trim(), column.trim());
495                col = apply_fk_action_options(col, &parts, &mut i)?;
496            }
497            s if s.starts_with("check(") => {
498                // Parse check(expr) — expression may contain nested parens and spaces.
499                // Keep consuming tokens until the outer `check(` parenthesis is balanced.
500                let mut check_str = s.to_string();
501                let mut quote = None;
502                let mut depth = paren_delta_ignoring_quotes(s, &mut quote);
503
504                while (depth > 0 || quote.is_some()) && i + 1 < parts.len() {
505                    i += 1;
506                    check_str.push(' ');
507                    check_str.push_str(parts[i]);
508                    depth += paren_delta_ignoring_quotes(parts[i], &mut quote);
509                }
510                if quote.is_some() {
511                    return Err(format!(
512                        "unterminated quote in check expression for column '{}'",
513                        name
514                    ));
515                }
516                if depth != 0 {
517                    return Err(format!("unclosed check expression for column '{}'", name));
518                }
519
520                // Strip "check(" and trailing ")"
521                let inner = check_str
522                    .strip_prefix("check(")
523                    .and_then(|s| s.strip_suffix(')'))
524                    .ok_or_else(|| format!("invalid check expression for column '{}'", name))?
525                    .trim();
526                if inner.is_empty() {
527                    return Err(format!("check expression is empty for column '{}'", name));
528                }
529                let expr = parse_check_expr_from_qail(inner).ok_or_else(|| {
530                    format!("invalid check expression for column '{}': {}", name, inner)
531                })?;
532                push_column_check(&mut col, CheckConstraint { expr, name: None });
533            }
534            "check_name" if i + 1 < parts.len() => {
535                i += 1;
536                if let Some(check) = last_column_check_mut(&mut col) {
537                    if check.name.is_some() {
538                        return Err(format!("duplicate check_name for column '{}'", name));
539                    }
540                    check.name = Some(parts[i].to_string());
541                } else {
542                    return Err(format!(
543                        "check_name requires a preceding check expression for column '{}'",
544                        name
545                    ));
546                }
547            }
548            "check_name" => {
549                return Err(format!("check_name requires a name for column '{}'", name));
550            }
551            _ => {
552                return Err(format!(
553                    "unknown column option '{}' for column '{}'",
554                    parts[i], name
555                ));
556            }
557        }
558        i += 1;
559    }
560
561    Ok(col)
562}
563
564fn paren_delta_ignoring_quotes(raw: &str, quote: &mut Option<char>) -> i32 {
565    let mut delta = 0i32;
566    let mut chars = raw.chars().peekable();
567
568    while let Some(ch) = chars.next() {
569        if let Some(q) = *quote {
570            if ch == q {
571                if chars.peek().is_some_and(|next| *next == q) {
572                    chars.next();
573                } else {
574                    *quote = None;
575                }
576            }
577            continue;
578        }
579
580        match ch {
581            '\'' | '"' => *quote = Some(ch),
582            '(' => delta += 1,
583            ')' => delta -= 1,
584            _ => {}
585        }
586    }
587
588    delta
589}
590
591fn parse_column_type_prefix(
592    parts: &[&str],
593    enum_types: &[EnumType],
594    column_name: &str,
595) -> Result<(ColumnType, usize), String> {
596    let max_end = parts.len().min(5);
597    for end in (2..=max_end).rev() {
598        let type_str = parts[1..end].join(" ");
599        if let Ok(data_type) = type_str.parse::<ColumnType>() {
600            return Ok((data_type, end));
601        }
602        if let Some(et) = enum_types.iter().find(|e| e.name == type_str) {
603            return Ok((
604                ColumnType::Enum {
605                    name: et.name.clone(),
606                    values: et.values.clone(),
607                },
608                end,
609            ));
610        }
611    }
612
613    Err(format!(
614        "Unknown column type '{}' for column '{}'",
615        parts[1], column_name
616    ))
617}
618
619/// Parse an index definition.
620fn parse_index(line: &str) -> Result<Index, String> {
621    let (is_unique, rest) = if let Some(rest) = line.strip_prefix("unique index ") {
622        (true, rest)
623    } else {
624        (
625            false,
626            line.strip_prefix("index ")
627                .ok_or("Expected 'index' prefix")?,
628        )
629    };
630    let (concurrently, rest) = if let Some(rest) = rest.strip_prefix("concurrently ") {
631        (true, rest)
632    } else {
633        (false, rest)
634    };
635
636    let parts: Vec<&str> = rest.splitn(2, " on ").collect();
637    if parts.len() != 2 {
638        return Err(format!("Invalid index: {}", line));
639    }
640
641    let name = parts[0].trim().to_string();
642    if name.is_empty() {
643        return Err("index name is required".to_string());
644    }
645    if !is_native_table_ref(&name) {
646        return Err(format!("invalid index name '{}'", name));
647    }
648    let rest = parts[1];
649
650    let paren_start = rest.find('(').ok_or("Missing ( in index")?;
651    let paren_end = find_matching_paren(rest, paren_start).ok_or("Missing ) in index")?;
652
653    let before_cols = rest[..paren_start].trim();
654    let (table, method) = if let Some((tbl, method)) = before_cols.split_once(" using ") {
655        (
656            tbl.trim().to_string(),
657            Some(parse_index_method_str(method)?),
658        )
659    } else {
660        (before_cols.to_string(), None)
661    };
662    if table.trim().is_empty() {
663        return Err("index table is required".to_string());
664    }
665    if !is_native_table_ref(&table) {
666        return Err(format!("invalid index table '{}'", table));
667    }
668    let cols_str = &rest[paren_start + 1..paren_end];
669    let columns: Vec<String> = split_top_level_csv(cols_str)?;
670    if columns.is_empty() {
671        return Err("index columns are required".to_string());
672    }
673
674    // Detect expression indexes: columns contain parentheses like "(lower(email))"
675    let has_expressions = columns
676        .iter()
677        .any(|c| c.starts_with('(') || c.contains("("));
678
679    let mut index = if has_expressions {
680        Index::expression(&name, &table, columns)
681    } else {
682        Index::new(&name, &table, columns)
683    };
684    if is_unique {
685        index.unique = true;
686    }
687    if let Some(method) = method {
688        index.method = method;
689    }
690    if concurrently {
691        index.concurrently = true;
692    }
693
694    let mut trailing = rest[paren_end + 1..].trim();
695    if let Some(include_rest) = trailing.strip_prefix("include ") {
696        let include_rest = include_rest.trim_start();
697        if !include_rest.starts_with('(') {
698            return Err("index include clause requires column list".to_string());
699        }
700        let include_end =
701            find_matching_paren(include_rest, 0).ok_or("Missing ) in index include")?;
702        let include_cols = split_top_level_csv(&include_rest[1..include_end])?;
703        if include_cols.is_empty() {
704            return Err("index include columns are required".to_string());
705        }
706        index.include = include_cols;
707        trailing = include_rest[include_end + 1..].trim();
708    }
709
710    if let Some(pred) = trailing.strip_prefix("where ") {
711        let pred = pred.trim();
712        if pred.is_empty() {
713            return Err("index where clause is empty".to_string());
714        }
715        index.where_clause = Some(CheckExpr::Sql(pred.to_string()));
716    } else if !trailing.is_empty() {
717        return Err("trailing content after index definition".to_string());
718    }
719
720    Ok(index)
721}
722
723fn split_top_level_csv(s: &str) -> Result<Vec<String>, String> {
724    let mut out = Vec::new();
725    let mut cur = String::new();
726    let mut depth = 0usize;
727    let mut quote: Option<char> = None;
728    let mut chars = s.chars().peekable();
729
730    while let Some(ch) = chars.next() {
731        if let Some(q) = quote {
732            cur.push(ch);
733            if ch == q {
734                if chars.peek().is_some_and(|next| *next == q) {
735                    cur.push(ch);
736                    chars.next();
737                } else {
738                    quote = None;
739                }
740            }
741            continue;
742        }
743
744        match ch {
745            '\'' | '"' => {
746                quote = Some(ch);
747                cur.push(ch);
748            }
749            '(' => {
750                depth += 1;
751                cur.push(ch);
752            }
753            ')' => {
754                if depth == 0 {
755                    return Err("unbalanced parentheses in index columns".to_string());
756                }
757                depth -= 1;
758                cur.push(ch);
759            }
760            ',' if depth == 0 => {
761                let piece = cur.trim();
762                if piece.is_empty() {
763                    return Err("empty index column or expression".to_string());
764                }
765                out.push(piece.to_string());
766                cur.clear();
767            }
768            _ => cur.push(ch),
769        }
770    }
771
772    if quote.is_some() {
773        return Err("unterminated quote in index columns".to_string());
774    }
775    if depth != 0 {
776        return Err("unbalanced parentheses in index columns".to_string());
777    }
778    let tail = cur.trim();
779    if tail.is_empty() {
780        if !s.trim().is_empty() {
781            return Err("empty index column or expression".to_string());
782        }
783    } else {
784        out.push(tail.to_string());
785    }
786    Ok(out)
787}
788
789/// Parse a rename hint.
790fn parse_rename(line: &str) -> Result<MigrationHint, String> {
791    // rename users.username -> users.name
792    let rest = line
793        .strip_prefix("rename ")
794        .ok_or("Expected 'rename' prefix")?;
795    let (from, to) = rest
796        .split_once("->")
797        .ok_or_else(|| format!("Invalid rename: {}", line))?;
798    let from = from.trim();
799    let to = to.trim();
800    if from.is_empty() || to.is_empty() {
801        return Err(format!(
802            "rename requires non-empty source and target: {}",
803            line
804        ));
805    }
806
807    Ok(MigrationHint::Rename {
808        from: from.to_string(),
809        to: to.to_string(),
810    })
811}
812
813/// Parse a transform hint.
814fn parse_transform(line: &str) -> Result<MigrationHint, String> {
815    // transform age * 12 -> age_months
816    let rest = line
817        .strip_prefix("transform ")
818        .ok_or("Expected 'transform' prefix")?;
819    let (expression, target) = rest
820        .split_once("->")
821        .ok_or_else(|| format!("Invalid transform: {}", line))?;
822    let expression = expression.trim();
823    let target = target.trim();
824    if expression.is_empty() || target.is_empty() {
825        return Err(format!(
826            "transform requires non-empty expression and target: {}",
827            line
828        ));
829    }
830
831    Ok(MigrationHint::Transform {
832        expression: expression.to_string(),
833        target: target.to_string(),
834    })
835}
836
837/// Parse a drop hint.
838fn parse_drop(line: &str) -> Result<MigrationHint, String> {
839    // drop temp_table confirm
840    let rest = line.strip_prefix("drop ").ok_or("Expected 'drop' prefix")?;
841    let confirmed = rest.ends_with(" confirm");
842    let target = if confirmed {
843        rest.strip_suffix(" confirm")
844            .ok_or("Expected 'confirm' suffix")?
845            .trim()
846            .to_string()
847    } else {
848        rest.trim().to_string()
849    };
850    if target.is_empty() {
851        return Err("drop requires a target".to_string());
852    }
853
854    Ok(MigrationHint::Drop { target, confirmed })
855}
856
857/// Parse an extension definition.
858/// Syntax: `extension "uuid-ossp"` or `extension pgcrypto`
859///         `extension "uuid-ossp" schema public version "1.1"`
860fn parse_extension(line: &str) -> Result<Extension, String> {
861    let rest = line
862        .strip_prefix("extension ")
863        .ok_or("Expected 'extension' prefix")?
864        .trim();
865    let parts = split_extension_tokens(rest)?;
866
867    if parts.is_empty() {
868        return Err("extension requires a name".to_string());
869    }
870
871    let mut ext = Extension::new(&parts[0]);
872    let mut i = 1;
873    let mut seen_options = HashSet::new();
874    while i < parts.len() {
875        match parts[i].as_str() {
876            "schema" if i + 1 < parts.len() => {
877                if !seen_options.insert("schema") {
878                    return Err("duplicate extension option: schema".to_string());
879                }
880                ext = ext.schema(&parts[i + 1]);
881                i += 2;
882            }
883            "version" if i + 1 < parts.len() => {
884                if !seen_options.insert("version") {
885                    return Err("duplicate extension option: version".to_string());
886                }
887                ext = ext.version(&parts[i + 1]);
888                i += 2;
889            }
890            _ => return Err(format!("Unknown extension option: {}", parts[i])),
891        }
892    }
893
894    Ok(ext)
895}
896
897fn split_extension_tokens(rest: &str) -> Result<Vec<String>, String> {
898    let mut parts = Vec::new();
899    let mut current = String::new();
900    let mut in_quotes = false;
901    let mut chars = rest.chars().peekable();
902
903    while let Some(ch) = chars.next() {
904        match ch {
905            '"' if in_quotes => {
906                if chars.peek().is_some_and(|next| *next == '"') {
907                    current.push('"');
908                    chars.next();
909                } else {
910                    in_quotes = false;
911                }
912            }
913            '"' => in_quotes = true,
914            c if c.is_whitespace() && !in_quotes => {
915                if !current.is_empty() {
916                    parts.push(std::mem::take(&mut current));
917                }
918            }
919            c => current.push(c),
920        }
921    }
922    if in_quotes {
923        return Err("unterminated quoted extension token".to_string());
924    }
925    if !current.is_empty() {
926        parts.push(current);
927    }
928
929    Ok(parts)
930}
931
932/// Parse a comment definition.
933/// Syntax: `comment on users "User accounts table"`
934///         `comment on users.email "Primary contact email"`
935fn parse_comment(line: &str) -> Result<Comment, String> {
936    let rest = line
937        .strip_prefix("comment on ")
938        .ok_or_else(|| "comment must use 'comment on <target> \"text\"'".to_string())?
939        .trim();
940
941    let quote_start = rest
942        .find('"')
943        .ok_or_else(|| "comment text must be quoted".to_string())?;
944    let target_str = rest[..quote_start].trim();
945    if target_str.is_empty() {
946        return Err("comment target is required".to_string());
947    }
948    let text = parse_comment_text(&rest[quote_start..])?;
949
950    if is_comment_raw_target(target_str) {
951        Ok(Comment::on_raw(target_str, text))
952    } else if target_str.contains('.') {
953        let (table, column) = target_str
954            .rsplit_once('.')
955            .ok_or_else(|| "invalid comment target".to_string())?;
956        let table = table.trim();
957        let column = column.trim();
958        if table.is_empty() || column.is_empty() {
959            return Err("invalid comment target".to_string());
960        }
961        if !is_native_table_ref(table) || !is_native_identifier(column) {
962            return Err("invalid comment target".to_string());
963        }
964        Ok(Comment::on_column(table, column, text))
965    } else {
966        if !is_native_table_ref(target_str) {
967            return Err("invalid comment target".to_string());
968        }
969        Ok(Comment::on_table(target_str, text))
970    }
971}
972
973fn parse_comment_text(raw: &str) -> Result<String, String> {
974    let mut chars = raw.char_indices().peekable();
975    match chars.next() {
976        Some((_, '"')) => {}
977        _ => return Err("comment text must be quoted".to_string()),
978    }
979
980    let mut text = String::new();
981    while let Some((idx, ch)) = chars.next() {
982        if ch == '"' {
983            if chars.peek().is_some_and(|(_, next)| *next == '"') {
984                text.push('"');
985                chars.next();
986                continue;
987            }
988
989            let after = idx + ch.len_utf8();
990            if !raw[after..].trim().is_empty() {
991                if raw[after..].contains('"') {
992                    text.push('"');
993                    continue;
994                }
995                return Err("trailing content after comment text".to_string());
996            }
997            return Ok(text);
998        }
999        text.push(ch);
1000    }
1001
1002    Err("unterminated comment text".to_string())
1003}
1004
1005fn is_comment_raw_target(target: &str) -> bool {
1006    let t = target.trim().to_ascii_lowercase();
1007    t.starts_with("function ")
1008        || t.starts_with("type ")
1009        || t.starts_with("policy ")
1010        || t.starts_with("constraint ")
1011        || t.starts_with("index ")
1012        || t.starts_with("sequence ")
1013        || t.starts_with("view ")
1014        || t.starts_with("materialized view ")
1015        || t.starts_with("schema ")
1016}
1017
1018/// Parse a sequence definition.
1019/// Single-line: `sequence order_number_seq`
1020/// Multi-line:  `sequence order_number_seq { start 1000 increment 1 cache 10 }`
1021fn parse_sequence<'a, I: Iterator<Item = &'a str>>(
1022    first_line: &str,
1023    lines: &mut std::iter::Peekable<I>,
1024) -> Result<Sequence, String> {
1025    let rest = first_line
1026        .strip_prefix("sequence ")
1027        .ok_or("Expected 'sequence' prefix")?
1028        .trim();
1029
1030    if rest.contains('{') {
1031        let name = rest
1032            .split('{')
1033            .next()
1034            .ok_or_else(|| "sequence name is missing before '{'".to_string())?
1035            .trim();
1036        if name.is_empty() {
1037            return Err("sequence name is missing before '{'".to_string());
1038        }
1039        if !is_native_table_ref(name) {
1040            return Err(format!("invalid sequence name '{}'", name));
1041        }
1042        let mut seq = Sequence::new(name);
1043
1044        let mut tokens_str = rest.split('{').nth(1).unwrap_or("").to_string();
1045        let mut found_closing_brace = tokens_str.contains('}');
1046
1047        if !found_closing_brace {
1048            for line in lines.by_ref() {
1049                let line = line.trim();
1050                if is_blank_or_qail_comment(line) {
1051                    continue;
1052                }
1053                tokens_str.push(' ');
1054                tokens_str.push_str(line);
1055                if line.contains('}') {
1056                    found_closing_brace = true;
1057                    break;
1058                }
1059            }
1060        }
1061        if !found_closing_brace {
1062            return Err(format!("Unclosed sequence block '{}'", name));
1063        }
1064
1065        let Some(close_idx) = tokens_str.find('}') else {
1066            return Err(format!("Unclosed sequence block '{}'", name));
1067        };
1068        if !tokens_str[close_idx + 1..].trim().is_empty() {
1069            return Err("trailing content after sequence block".to_string());
1070        }
1071        let tokens_str = &tokens_str[..close_idx];
1072        let tokens: Vec<&str> = tokens_str.split_whitespace().collect();
1073
1074        let mut i = 0;
1075        let mut seen_options = HashSet::new();
1076        while i < tokens.len() {
1077            match tokens[i] {
1078                "start" if i + 1 < tokens.len() => {
1079                    record_sequence_option(&mut seen_options, "start")?;
1080                    seq.start = Some(tokens[i + 1].parse().map_err(|_| "invalid start value")?);
1081                    i += 2;
1082                }
1083                "increment" if i + 1 < tokens.len() => {
1084                    record_sequence_option(&mut seen_options, "increment")?;
1085                    seq.increment = Some(
1086                        tokens[i + 1]
1087                            .parse()
1088                            .map_err(|_| "invalid increment value")?,
1089                    );
1090                    i += 2;
1091                }
1092                "minvalue" if i + 1 < tokens.len() => {
1093                    record_sequence_option(&mut seen_options, "minvalue")?;
1094                    seq.min_value = Some(tokens[i + 1].parse().map_err(|_| "invalid minvalue")?);
1095                    i += 2;
1096                }
1097                "maxvalue" if i + 1 < tokens.len() => {
1098                    record_sequence_option(&mut seen_options, "maxvalue")?;
1099                    seq.max_value = Some(tokens[i + 1].parse().map_err(|_| "invalid maxvalue")?);
1100                    i += 2;
1101                }
1102                "cache" if i + 1 < tokens.len() => {
1103                    record_sequence_option(&mut seen_options, "cache")?;
1104                    seq.cache = Some(tokens[i + 1].parse().map_err(|_| "invalid cache value")?);
1105                    i += 2;
1106                }
1107                "cycle" => {
1108                    record_sequence_option(&mut seen_options, "cycle")?;
1109                    seq.cycle = true;
1110                    i += 1;
1111                }
1112                "owned_by" if i + 1 < tokens.len() => {
1113                    record_sequence_option(&mut seen_options, "owned_by")?;
1114                    if !is_native_column_ref(tokens[i + 1]) {
1115                        return Err(format!(
1116                            "invalid sequence owned_by reference '{}'",
1117                            tokens[i + 1]
1118                        ));
1119                    }
1120                    seq.owned_by = Some(tokens[i + 1].to_string());
1121                    i += 2;
1122                }
1123                "as" if i + 1 < tokens.len() => {
1124                    record_sequence_option(&mut seen_options, "as")?;
1125                    if !is_native_identifier(tokens[i + 1]) {
1126                        return Err(format!("invalid sequence data type '{}'", tokens[i + 1]));
1127                    }
1128                    seq.data_type = Some(tokens[i + 1].to_string());
1129                    i += 2;
1130                }
1131                _ => return Err(format!("Unknown sequence option: {}", tokens[i])),
1132            }
1133        }
1134
1135        Ok(seq)
1136    } else {
1137        if rest.is_empty() {
1138            return Err("sequence name is required".to_string());
1139        }
1140        if !is_native_table_ref(rest) {
1141            return Err(format!("invalid sequence name '{}'", rest));
1142        }
1143        Ok(Sequence::new(rest))
1144    }
1145}
1146
1147fn record_sequence_option(
1148    seen_options: &mut HashSet<&'static str>,
1149    option: &'static str,
1150) -> Result<(), String> {
1151    if !seen_options.insert(option) {
1152        return Err(format!("duplicate sequence option: {option}"));
1153    }
1154    Ok(())
1155}
1156
1157fn push_column_check(col: &mut Column, check: CheckConstraint) {
1158    if col.check.is_none() {
1159        col.check = Some(check);
1160    } else {
1161        col.extra_checks.push(check);
1162    }
1163}
1164
1165fn last_column_check_mut(col: &mut Column) -> Option<&mut CheckConstraint> {
1166    if let Some(check) = col.extra_checks.last_mut() {
1167        Some(check)
1168    } else {
1169        col.check.as_mut()
1170    }
1171}
1172
1173/// Parse a standalone ENUM type definition.
1174/// Syntax: `enum status { active, inactive, pending }`
1175///         or multi-line block
1176fn parse_enum<'a, I: Iterator<Item = &'a str>>(
1177    first_line: &str,
1178    lines: &mut std::iter::Peekable<I>,
1179) -> Result<EnumType, String> {
1180    let rest = first_line
1181        .strip_prefix("enum ")
1182        .ok_or("Expected 'enum' prefix")?
1183        .trim();
1184
1185    if rest.contains('{') {
1186        let name = rest
1187            .split('{')
1188            .next()
1189            .ok_or_else(|| "enum name is missing before '{'".to_string())?
1190            .trim();
1191        if name.is_empty() {
1192            return Err("enum name is missing before '{'".to_string());
1193        }
1194
1195        let mut values_str = rest.split('{').nth(1).unwrap_or("").to_string();
1196
1197        if enum_body_before_closing_brace(&values_str)?.is_none() {
1198            for line in lines.by_ref() {
1199                let line = line.trim();
1200                if is_blank_or_qail_comment(line) {
1201                    continue;
1202                }
1203                values_str.push(' ');
1204                values_str.push_str(line);
1205                if enum_body_before_closing_brace(&values_str)?.is_some() {
1206                    break;
1207                }
1208            }
1209        }
1210
1211        let values_str = enum_body_before_closing_brace(&values_str)?
1212            .ok_or_else(|| format!("enum '{}' is missing closing '}}'", name))?;
1213        let values = parse_enum_values(values_str)?;
1214
1215        if values.is_empty() {
1216            return Err(format!("enum '{}' must have at least one value", name));
1217        }
1218
1219        Ok(EnumType::new(name, values))
1220    } else {
1221        Err("enum definition requires { values }".to_string())
1222    }
1223}
1224
1225fn 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_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_enum_value(&mut values, &raw[start..idx])?;
1279                start = idx + ch.len_utf8();
1280            }
1281            _ => {}
1282        }
1283    }
1284
1285    push_enum_value(&mut values, &raw[start..])?;
1286    let mut seen = HashSet::new();
1287    for value in &values {
1288        if !seen.insert(value) {
1289            return Err(format!("duplicate enum value '{}'", value));
1290        }
1291    }
1292    Ok(values)
1293}
1294
1295fn push_enum_value(values: &mut Vec<String>, raw: &str) -> Result<(), String> {
1296    if raw.trim().is_empty() {
1297        return Err("enum value is empty".to_string());
1298    }
1299
1300    let value = parse_enum_value(raw)?;
1301    values.push(value);
1302    Ok(())
1303}
1304
1305fn parse_enum_value(raw: &str) -> Result<String, String> {
1306    let trimmed = raw.trim();
1307    let Some(quote) = trimmed.chars().next().filter(|ch| matches!(ch, '\'' | '"')) else {
1308        return Ok(trimmed.to_string());
1309    };
1310
1311    let mut value = String::new();
1312    let mut chars = trimmed.char_indices();
1313    chars.next();
1314    let mut chars = chars.peekable();
1315
1316    while let Some((idx, ch)) = chars.next() {
1317        if ch == quote {
1318            if chars.peek().is_some_and(|(_, next)| *next == quote) {
1319                value.push(quote);
1320                chars.next();
1321                continue;
1322            }
1323
1324            let after = idx + ch.len_utf8();
1325            if !trimmed[after..].trim().is_empty() {
1326                return Err(format!("invalid enum value token '{}'", trimmed));
1327            }
1328            return Ok(value);
1329        }
1330
1331        value.push(ch);
1332    }
1333
1334    Err(format!("unterminated quoted enum value '{}'", trimmed))
1335}
1336
1337/// Parse a table-level multi-column foreign key.
1338/// Syntax: `foreign_key (a, b) references other_table(x, y)`
1339fn parse_multi_column_fk(line: &str) -> Result<MultiColumnForeignKey, String> {
1340    let rest = line.strip_prefix("foreign_key").unwrap_or(line).trim();
1341
1342    // Extract local columns from (...)
1343    let local_start = rest.find('(').ok_or("foreign_key missing ( for columns")?;
1344    let local_end = rest.find(')').ok_or("foreign_key missing ) for columns")?;
1345    let local_cols: Vec<String> = rest[local_start + 1..local_end]
1346        .split(',')
1347        .map(|s| s.trim().to_string())
1348        .collect();
1349    if local_cols.is_empty() || local_cols.iter().any(|col| col.is_empty()) {
1350        return Err("foreign_key local columns are required".to_string());
1351    }
1352    let mut seen_local_cols = HashSet::new();
1353    for col in &local_cols {
1354        if !is_native_identifier(col) {
1355            return Err(format!("invalid foreign_key local column '{}'", col));
1356        }
1357        if !seen_local_cols.insert(col) {
1358            return Err(format!("duplicate foreign_key local column '{}'", col));
1359        }
1360    }
1361
1362    // After first ) find "references"
1363    let after_locals = rest[local_end + 1..].trim();
1364    let ref_part = after_locals
1365        .strip_prefix("references")
1366        .ok_or("foreign_key missing 'references' keyword")?
1367        .trim();
1368
1369    // Extract ref table and ref columns from table(cols)
1370    let ref_paren_start = ref_part.find('(').ok_or("foreign_key ref missing (")?;
1371    let ref_paren_end = ref_part.find(')').ok_or("foreign_key ref missing )")?;
1372
1373    let ref_table = ref_part[..ref_paren_start].trim().to_string();
1374    if ref_table.is_empty() {
1375        return Err("foreign_key referenced table is required".to_string());
1376    }
1377    if !is_native_table_ref(&ref_table) {
1378        return Err(format!(
1379            "invalid foreign_key referenced table '{}'",
1380            ref_table
1381        ));
1382    }
1383    let ref_cols: Vec<String> = ref_part[ref_paren_start + 1..ref_paren_end]
1384        .split(',')
1385        .map(|s| s.trim().to_string())
1386        .collect();
1387    let trailing = ref_part[ref_paren_end + 1..].trim();
1388    if ref_cols.is_empty() || ref_cols.iter().any(|col| col.is_empty()) {
1389        return Err("foreign_key referenced columns are required".to_string());
1390    }
1391    let mut seen_ref_cols = HashSet::new();
1392    for col in &ref_cols {
1393        if !is_native_identifier(col) {
1394            return Err(format!("invalid foreign_key referenced column '{}'", col));
1395        }
1396        if !seen_ref_cols.insert(col) {
1397            return Err(format!("duplicate foreign_key referenced column '{}'", col));
1398        }
1399    }
1400    if local_cols.len() != ref_cols.len() {
1401        return Err("foreign_key local/ref column counts must match".to_string());
1402    }
1403
1404    let mut fk = MultiColumnForeignKey::new(local_cols, ref_table, ref_cols);
1405    if !trailing.is_empty() {
1406        apply_multi_column_fk_options(&mut fk, trailing)?;
1407    }
1408
1409    Ok(fk)
1410}
1411
1412fn apply_multi_column_fk_options(
1413    fk: &mut MultiColumnForeignKey,
1414    trailing: &str,
1415) -> Result<(), String> {
1416    let parts: Vec<&str> = trailing.split_whitespace().collect();
1417    let mut i = 0;
1418    let mut seen_name = false;
1419    let mut seen_on_delete = false;
1420    let mut seen_on_update = false;
1421    let mut seen_deferrable = false;
1422
1423    while i < parts.len() {
1424        match parts[i] {
1425            "constraint" | "name" if i + 1 < parts.len() => {
1426                if seen_name {
1427                    return Err("duplicate foreign_key constraint name".to_string());
1428                }
1429                let name = parts[i + 1];
1430                if !is_native_identifier(name) {
1431                    return Err(format!("invalid foreign_key constraint name '{}'", name));
1432                }
1433                seen_name = true;
1434                fk.name = Some(name.to_string());
1435                i += 2;
1436            }
1437            "constraint" | "name" => {
1438                return Err(format!("{} requires a constraint name", parts[i]));
1439            }
1440            "on_delete" if i + 1 < parts.len() => {
1441                if seen_on_delete {
1442                    return Err("duplicate on_delete action".to_string());
1443                }
1444                seen_on_delete = true;
1445                fk.on_delete = parse_fk_action_str(parts[i + 1])?;
1446                i += 2;
1447            }
1448            "on_update" if i + 1 < parts.len() => {
1449                if seen_on_update {
1450                    return Err("duplicate on_update action".to_string());
1451                }
1452                seen_on_update = true;
1453                fk.on_update = parse_fk_action_str(parts[i + 1])?;
1454                i += 2;
1455            }
1456            "on_delete" | "on_update" => {
1457                return Err(format!("{} requires a foreign key action", parts[i]));
1458            }
1459            "deferrable" => {
1460                if seen_deferrable {
1461                    return Err("duplicate foreign_key deferrable option".to_string());
1462                }
1463                seen_deferrable = true;
1464                fk.deferrable = Deferrable::Deferrable;
1465                i += 1;
1466            }
1467            "initially_deferred" => {
1468                if seen_deferrable {
1469                    return Err("duplicate foreign_key deferrable option".to_string());
1470                }
1471                seen_deferrable = true;
1472                fk.deferrable = Deferrable::InitiallyDeferred;
1473                i += 1;
1474            }
1475            "initially_immediate" => {
1476                if seen_deferrable {
1477                    return Err("duplicate foreign_key deferrable option".to_string());
1478                }
1479                seen_deferrable = true;
1480                fk.deferrable = Deferrable::InitiallyImmediate;
1481                i += 1;
1482            }
1483            unknown => {
1484                return Err(format!(
1485                    "unknown foreign_key option '{}' after references",
1486                    unknown
1487                ));
1488            }
1489        }
1490    }
1491
1492    Ok(())
1493}
1494
1495/// Parse a view definition.
1496/// Syntax: `view name $$ SELECT ... $$`
1497///     or: `materialized view name $$ SELECT ... $$`
1498///     or multi-line block
1499fn parse_view<'a, I: Iterator<Item = &'a str>>(
1500    first_line: &str,
1501    lines: &mut std::iter::Peekable<I>,
1502) -> Result<ViewDef, String> {
1503    let materialized = first_line.starts_with("materialized ");
1504    let rest = if materialized {
1505        first_line
1506            .strip_prefix("materialized view ")
1507            .ok_or("Expected 'materialized view' prefix")?
1508            .trim()
1509    } else {
1510        first_line
1511            .strip_prefix("view ")
1512            .ok_or("Expected 'view' prefix")?
1513            .trim()
1514    };
1515
1516    if let Some((dollar_pos, delimiter)) = find_dollar_delimiter(rest) {
1517        let mut name = rest[..dollar_pos].trim();
1518        // `view <name> [security_invoker] $$ … $$` — the modifier makes the
1519        // view evaluate its base tables with the caller's rights so their RLS
1520        // still applies (see ViewDef::security_invoker).
1521        let mut security_invoker = false;
1522        if let Some(stripped) = name.strip_suffix("security_invoker")
1523            && (stripped.is_empty() || stripped.ends_with(char::is_whitespace))
1524        {
1525            security_invoker = true;
1526            name = stripped.trim();
1527        }
1528        if name.is_empty() {
1529            return Err("view name is required".to_string());
1530        }
1531        if !is_native_table_ref(name) {
1532            return Err(format!("invalid view name '{}'", name));
1533        }
1534        let body = collect_dollar_body(
1535            &rest[dollar_pos + delimiter.len()..],
1536            lines,
1537            &delimiter,
1538            "view",
1539        )?;
1540
1541        let mut view = ViewDef::new(name, body.trim());
1542        if materialized {
1543            view = view.materialized();
1544        }
1545        if security_invoker {
1546            view = view.security_invoker();
1547        }
1548        Ok(view)
1549    } else {
1550        Err("view body must be wrapped in a dollar-quoted block".to_string())
1551    }
1552}
1553
1554/// Parse a function definition.
1555/// Syntax: `function name(args) returns type language lang $$ body $$`
1556fn parse_function<'a, I: Iterator<Item = &'a str>>(
1557    first_line: &str,
1558    lines: &mut std::iter::Peekable<I>,
1559) -> Result<SchemaFunctionDef, String> {
1560    let rest = first_line
1561        .strip_prefix("function ")
1562        .ok_or("Expected 'function' prefix")?
1563        .trim();
1564
1565    // Extract name and args
1566    let paren_start = rest.find('(').ok_or("function missing (")?;
1567    let paren_end = find_matching_paren(rest, paren_start).ok_or("function missing )")?;
1568
1569    let name = rest[..paren_start].trim();
1570    if name.is_empty() {
1571        return Err("function name is required".to_string());
1572    }
1573    if !is_native_table_ref(name) {
1574        return Err(format!("invalid function name '{}'", name));
1575    }
1576    let args_str = &rest[paren_start + 1..paren_end];
1577    let args = split_function_args(args_str)?;
1578    validate_function_args(&args)?;
1579
1580    let after_args = rest[paren_end + 1..].trim();
1581
1582    let (body_start_idx, delimiter) = find_dollar_delimiter(after_args)
1583        .ok_or_else(|| "function body must be wrapped in a dollar-quoted block".to_string())?;
1584    let header = after_args[..body_start_idx].trim();
1585    let (returns, language, volatility) = parse_function_header(header)?;
1586
1587    let body = collect_dollar_body(
1588        &after_args[body_start_idx + delimiter.len()..],
1589        lines,
1590        &delimiter,
1591        "function",
1592    )?
1593    .trim()
1594    .to_string();
1595
1596    let mut func = SchemaFunctionDef::new(name, &returns, body);
1597    func.language = language;
1598    func.args = args;
1599    func.volatility = volatility;
1600
1601    Ok(func)
1602}
1603
1604fn find_matching_paren(raw: &str, open_idx: usize) -> Option<usize> {
1605    let mut depth = 0usize;
1606    let mut quote: Option<char> = None;
1607    let mut chars = raw[open_idx..].char_indices().peekable();
1608
1609    while let Some((relative_idx, ch)) = chars.next() {
1610        let idx = open_idx + relative_idx;
1611
1612        if let Some(q) = quote {
1613            if ch == q {
1614                if chars.peek().is_some_and(|(_, next)| *next == q) {
1615                    chars.next();
1616                } else {
1617                    quote = None;
1618                }
1619            }
1620            continue;
1621        }
1622
1623        match ch {
1624            '\'' | '"' => quote = Some(ch),
1625            '(' => depth += 1,
1626            ')' => {
1627                depth = depth.checked_sub(1)?;
1628                if depth == 0 {
1629                    return Some(idx);
1630                }
1631            }
1632            _ => {}
1633        }
1634    }
1635
1636    None
1637}
1638
1639fn split_function_args(args: &str) -> Result<Vec<String>, String> {
1640    let mut out = Vec::new();
1641    let mut start = 0;
1642    let mut depth = 0usize;
1643    let mut quote: Option<char> = None;
1644    let mut chars = args.char_indices().peekable();
1645
1646    while let Some((idx, ch)) = chars.next() {
1647        if let Some(q) = quote {
1648            if ch == q {
1649                if chars.peek().is_some_and(|(_, next)| *next == q) {
1650                    chars.next();
1651                } else {
1652                    quote = None;
1653                }
1654            }
1655            continue;
1656        }
1657
1658        match ch {
1659            '\'' | '"' => quote = Some(ch),
1660            '(' => depth += 1,
1661            ')' => {
1662                if depth == 0 {
1663                    return Err("unbalanced parentheses in function arguments".to_string());
1664                }
1665                depth -= 1;
1666            }
1667            ',' if depth == 0 => {
1668                let arg = args[start..idx].trim();
1669                if arg.is_empty() {
1670                    return Err("empty function argument".to_string());
1671                }
1672                out.push(arg.to_string());
1673                start = idx + ch.len_utf8();
1674            }
1675            _ => {}
1676        }
1677    }
1678
1679    if quote.is_some() {
1680        return Err("unterminated quote in function arguments".to_string());
1681    }
1682    if depth != 0 {
1683        return Err("unbalanced parentheses in function arguments".to_string());
1684    }
1685    let arg = args[start..].trim();
1686    if arg.is_empty() {
1687        if !args.trim().is_empty() {
1688            return Err("empty function argument".to_string());
1689        }
1690    } else {
1691        out.push(arg.to_string());
1692    }
1693
1694    Ok(out)
1695}
1696
1697fn validate_function_args(args: &[String]) -> Result<(), String> {
1698    let mut seen_names = HashSet::new();
1699    for arg in args {
1700        let Some(name) = function_arg_name(arg)? else {
1701            continue;
1702        };
1703        let key = name.to_ascii_lowercase();
1704        if !seen_names.insert(key) {
1705            return Err(format!("duplicate function argument '{}'", name));
1706        }
1707    }
1708    Ok(())
1709}
1710
1711fn function_arg_name(arg: &str) -> Result<Option<&str>, String> {
1712    let mut parts = arg.split_whitespace();
1713    let Some(first) = parts.next() else {
1714        return Ok(None);
1715    };
1716    let second = parts.next();
1717    let name = if matches!(
1718        first.to_ascii_lowercase().as_str(),
1719        "in" | "out" | "inout" | "variadic"
1720    ) {
1721        let Some(name) = second else {
1722            return Err(format!(
1723                "function argument mode '{}' requires a name",
1724                first
1725            ));
1726        };
1727        name
1728    } else if second.is_some() {
1729        first
1730    } else {
1731        return Ok(None);
1732    };
1733    if !is_native_identifier(name) {
1734        return Err(format!("invalid function argument name '{}'", name));
1735    }
1736    Ok(Some(name))
1737}
1738
1739#[derive(Debug)]
1740struct HeaderWord {
1741    start: usize,
1742    end: usize,
1743    depth: usize,
1744}
1745
1746fn parse_function_header(header: &str) -> Result<(String, String, Option<String>), String> {
1747    let words = header_word_spans(header);
1748    let returns_matches: Vec<usize> = words
1749        .iter()
1750        .enumerate()
1751        .filter_map(|(idx, word)| {
1752            (word.depth == 0 && header[word.start..word.end].eq_ignore_ascii_case("returns"))
1753                .then_some(idx)
1754        })
1755        .collect();
1756    if returns_matches.len() > 1 {
1757        return Err("function has duplicate returns clauses".to_string());
1758    }
1759    let language_matches: Vec<usize> = words
1760        .iter()
1761        .enumerate()
1762        .filter_map(|(idx, word)| {
1763            (word.depth == 0 && header[word.start..word.end].eq_ignore_ascii_case("language"))
1764                .then_some(idx)
1765        })
1766        .collect();
1767    if language_matches.len() > 1 {
1768        return Err("function has duplicate language clauses".to_string());
1769    }
1770    let returns_idx = returns_matches.first().copied();
1771    let language_idx = language_matches.first().copied();
1772    let volatility_matches: Vec<usize> = words
1773        .iter()
1774        .enumerate()
1775        .filter_map(|(idx, word)| {
1776            if word.depth != 0 {
1777                return None;
1778            }
1779            matches!(
1780                header[word.start..word.end].to_ascii_lowercase().as_str(),
1781                "volatile" | "stable" | "immutable"
1782            )
1783            .then_some(idx)
1784        })
1785        .collect();
1786    if volatility_matches.len() > 1 {
1787        return Err("function has duplicate volatility clauses".to_string());
1788    }
1789    let volatility_idx = volatility_matches.first().copied();
1790
1791    let returns_idx = returns_idx.ok_or_else(|| "function missing returns clause".to_string())?;
1792    let start = words[returns_idx].end;
1793    let end = [language_idx, volatility_idx]
1794        .into_iter()
1795        .flatten()
1796        .filter(|next_idx| *next_idx > returns_idx)
1797        .min()
1798        .map(|next_idx| words[next_idx].start)
1799        .unwrap_or(header.len());
1800    let returns = header[start..end].trim();
1801    if returns.is_empty() {
1802        return Err("function returns clause requires a type".to_string());
1803    }
1804
1805    let language_idx =
1806        language_idx.ok_or_else(|| "function missing language clause".to_string())?;
1807    let language = words
1808        .get(language_idx + 1)
1809        .map(|word| header[word.start..word.end].to_string())
1810        .filter(|value| !value.trim().is_empty())
1811        .ok_or_else(|| "function language clause requires a language".to_string())?;
1812    if !is_native_identifier(&language) {
1813        return Err(format!("invalid function language '{}'", language));
1814    }
1815
1816    let volatility =
1817        volatility_idx.map(|idx| header[words[idx].start..words[idx].end].to_ascii_lowercase());
1818
1819    let mut covered = vec![false; words.len()];
1820    covered[returns_idx] = true;
1821    for (idx, word) in words.iter().enumerate() {
1822        if word.start >= start && word.end <= end {
1823            covered[idx] = true;
1824        }
1825    }
1826    covered[language_idx] = true;
1827    if let Some(language_value_idx) = language_idx.checked_add(1)
1828        && language_value_idx < covered.len()
1829    {
1830        covered[language_value_idx] = true;
1831    }
1832    if let Some(idx) = volatility_idx {
1833        covered[idx] = true;
1834    }
1835    for (idx, word) in words.iter().enumerate() {
1836        if !covered[idx] {
1837            return Err(format!(
1838                "unknown function header token '{}'",
1839                &header[word.start..word.end]
1840            ));
1841        }
1842    }
1843
1844    Ok((returns.to_string(), language, volatility))
1845}
1846
1847fn header_word_spans(header: &str) -> Vec<HeaderWord> {
1848    let mut words = Vec::new();
1849    let mut start: Option<usize> = None;
1850    let mut start_depth = 0usize;
1851    let mut depth = 0usize;
1852    let mut quote: Option<char> = None;
1853    let mut chars = header.char_indices().peekable();
1854
1855    while let Some((idx, ch)) = chars.next() {
1856        if ch.is_whitespace() {
1857            if let Some(word_start) = start.take() {
1858                words.push(HeaderWord {
1859                    start: word_start,
1860                    end: idx,
1861                    depth: start_depth,
1862                });
1863            }
1864            continue;
1865        }
1866
1867        if start.is_none() {
1868            start = Some(idx);
1869            start_depth = depth;
1870        }
1871
1872        if let Some(q) = quote {
1873            if ch == q {
1874                if chars.peek().is_some_and(|(_, next)| *next == q) {
1875                    chars.next();
1876                } else {
1877                    quote = None;
1878                }
1879            }
1880            continue;
1881        }
1882
1883        match ch {
1884            '\'' | '"' => quote = Some(ch),
1885            '(' => depth += 1,
1886            ')' => depth = depth.saturating_sub(1),
1887            _ => {}
1888        }
1889    }
1890
1891    if let Some(word_start) = start {
1892        words.push(HeaderWord {
1893            start: word_start,
1894            end: header.len(),
1895            depth: start_depth,
1896        });
1897    }
1898
1899    words
1900}
1901
1902fn find_dollar_delimiter(raw: &str) -> Option<(usize, String)> {
1903    let mut search_start = 0;
1904    while let Some(relative_open) = raw[search_start..].find('$') {
1905        let open = search_start + relative_open;
1906        let tag_start = open + 1;
1907        let relative_close = raw[tag_start..].find('$')?;
1908        let close = tag_start + relative_close;
1909        let tag = &raw[tag_start..close];
1910        if tag
1911            .chars()
1912            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1913        {
1914            return Some((open, raw[open..=close].to_string()));
1915        }
1916        search_start = tag_start;
1917    }
1918
1919    None
1920}
1921
1922fn collect_dollar_body<'a, I: Iterator<Item = &'a str>>(
1923    first_fragment: &str,
1924    lines: &mut std::iter::Peekable<I>,
1925    delimiter: &str,
1926    kind: &str,
1927) -> Result<String, String> {
1928    let mut body = String::new();
1929    if let Some(closing) = first_fragment.find(delimiter) {
1930        body.push_str(&first_fragment[..closing]);
1931        let trailing = &first_fragment[closing + delimiter.len()..];
1932        if !trailing.trim().is_empty() {
1933            return Err(format!(
1934                "{kind} body has trailing content after closing delimiter"
1935            ));
1936        }
1937        return Ok(body);
1938    }
1939
1940    body.push_str(first_fragment);
1941    for line in lines.by_ref() {
1942        if let Some(closing) = line.find(delimiter) {
1943            body.push('\n');
1944            body.push_str(&line[..closing]);
1945            let trailing = &line[closing + delimiter.len()..];
1946            if !trailing.trim().is_empty() {
1947                return Err(format!(
1948                    "{kind} body has trailing content after closing delimiter"
1949                ));
1950            }
1951            return Ok(body);
1952        }
1953        body.push('\n');
1954        body.push_str(line);
1955    }
1956
1957    Err(format!(
1958        "{kind} body is missing closing delimiter {delimiter}"
1959    ))
1960}
1961
1962/// Parse a trigger definition.
1963/// Syntax: `trigger name on table before|after insert|update|delete execute function_name`
1964fn parse_trigger(line: &str) -> Result<SchemaTriggerDef, String> {
1965    let rest = line
1966        .strip_prefix("trigger ")
1967        .ok_or("Expected 'trigger' prefix")?
1968        .trim();
1969    let parts: Vec<&str> = rest.split_whitespace().collect();
1970
1971    if parts.len() < 6 {
1972        return Err("trigger requires: name on table timing event execute func".to_string());
1973    }
1974
1975    let name = parts[0];
1976    if !is_native_identifier(name) {
1977        return Err(format!("invalid trigger name '{}'", name));
1978    }
1979
1980    // Find "on" keyword
1981    let on_idx = parts
1982        .iter()
1983        .position(|&p| p == "on")
1984        .ok_or("trigger missing 'on' keyword")?;
1985    let table = parts.get(on_idx + 1).ok_or("trigger missing table name")?;
1986    if !is_native_table_ref(table) {
1987        return Err(format!("invalid trigger table '{}'", table));
1988    }
1989
1990    let timing = parts
1991        .get(on_idx + 2)
1992        .ok_or("trigger missing timing")?
1993        .to_uppercase();
1994    if !matches!(timing.as_str(), "BEFORE" | "AFTER") {
1995        return Err(format!("unsupported trigger timing: {timing}"));
1996    }
1997
1998    // Collect events (INSERT, UPDATE, DELETE, etc.) until "execute"
1999    let mut events = Vec::new();
2000    let mut update_columns = Vec::new();
2001    let mut exec_idx = None;
2002    for (j, part) in parts.iter().enumerate().skip(on_idx + 3) {
2003        if part.eq_ignore_ascii_case("execute") {
2004            exec_idx = Some(j);
2005            break;
2006        }
2007    }
2008
2009    let exec_idx = exec_idx.ok_or("trigger missing 'execute' keyword")?;
2010    let event_tokens = &parts[on_idx + 3..exec_idx];
2011    let mut chunks: Vec<Vec<&str>> = Vec::new();
2012    let mut current = Vec::new();
2013    for tok in event_tokens {
2014        if tok.eq_ignore_ascii_case("or") {
2015            if !current.is_empty() {
2016                chunks.push(current);
2017                current = Vec::new();
2018            }
2019            continue;
2020        }
2021        current.push(*tok);
2022    }
2023    if !current.is_empty() {
2024        chunks.push(current);
2025    }
2026
2027    let mut seen_events = HashSet::new();
2028    for chunk in chunks {
2029        if chunk.is_empty() {
2030            continue;
2031        }
2032        if chunk.len() >= 3
2033            && chunk[0].eq_ignore_ascii_case("update")
2034            && chunk[1].eq_ignore_ascii_case("of")
2035        {
2036            if !seen_events.insert("UPDATE".to_string()) {
2037                return Err("duplicate trigger event: UPDATE".to_string());
2038            }
2039            events.push("UPDATE".to_string());
2040            let before_count = update_columns.len();
2041            let mut seen_cols = HashSet::new();
2042            let cols = chunk[2..].join(" ");
2043            for col in cols.split(',') {
2044                let c = col.trim();
2045                if c.is_empty() {
2046                    return Err("trigger update of contains an empty column".to_string());
2047                }
2048                if !seen_cols.insert(c.to_string()) {
2049                    return Err(format!("duplicate trigger update column '{}'", c));
2050                }
2051                if !is_native_identifier(c) {
2052                    return Err(format!("invalid trigger update column '{}'", c));
2053                }
2054                update_columns.push(c.to_string());
2055            }
2056            if update_columns.len() == before_count {
2057                return Err("trigger update of requires at least one column".to_string());
2058            }
2059            continue;
2060        }
2061        let event = chunk.join(" ").to_uppercase();
2062        if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE" | "TRUNCATE") {
2063            return Err(format!("unsupported trigger event: {event}"));
2064        }
2065        if !seen_events.insert(event.clone()) {
2066            return Err(format!("duplicate trigger event: {event}"));
2067        }
2068        events.push(event);
2069    }
2070    if events.is_empty() {
2071        return Err("trigger requires at least one event".to_string());
2072    }
2073
2074    let func_name = parts
2075        .get(exec_idx + 1)
2076        .ok_or("trigger missing function name")?;
2077    if !is_native_table_ref(func_name) {
2078        return Err(format!("invalid trigger function '{}'", func_name));
2079    }
2080    if parts.len() > exec_idx + 2 {
2081        return Err("trailing content after trigger function".to_string());
2082    }
2083
2084    let mut trigger = SchemaTriggerDef::new(name, *table, *func_name);
2085    trigger.timing = timing;
2086    trigger.events = events;
2087    trigger.update_columns = update_columns;
2088
2089    Ok(trigger)
2090}
2091
2092/// Parse GRANT/REVOKE.
2093/// Syntax: `grant select, insert on users to app_role`
2094///     or: `revoke all on users from public`
2095fn parse_grant(line: &str) -> Result<Grant, String> {
2096    let is_revoke = line.starts_with("revoke ");
2097    let rest = if is_revoke {
2098        line.strip_prefix("revoke ")
2099            .ok_or("Expected 'revoke' prefix")?
2100    } else {
2101        line.strip_prefix("grant ")
2102            .ok_or("Expected 'grant' prefix")?
2103    }
2104    .trim();
2105
2106    // Find "on" keyword
2107    let on_idx = rest
2108        .find(" on ")
2109        .ok_or("grant/revoke missing 'on' keyword")?;
2110    let privs_str = &rest[..on_idx].trim();
2111    let after_on = rest[on_idx + 4..].trim();
2112
2113    // Find "to" or "from" keyword
2114    let (obj_str, role_str) = if is_revoke {
2115        split_grant_subject(after_on, "from")
2116            .ok_or_else(|| "revoke missing 'from' keyword".to_string())?
2117    } else {
2118        split_grant_subject(after_on, "to")
2119            .ok_or_else(|| "grant missing 'to' keyword".to_string())?
2120    };
2121    if obj_str.trim().is_empty() {
2122        return Err("grant/revoke object is required".to_string());
2123    }
2124    if role_str.trim().is_empty() {
2125        return Err("grant/revoke role is required".to_string());
2126    }
2127
2128    let mut privileges = Vec::new();
2129    let mut seen_privileges = HashSet::new();
2130    for raw_privilege in privs_str.split(',') {
2131        let privilege_key = raw_privilege.trim().to_uppercase();
2132        let privilege = parse_privilege(raw_privilege)?;
2133        if privilege_key == "ALL" && !seen_privileges.is_empty()
2134            || privilege_key != "ALL" && seen_privileges.contains("ALL")
2135        {
2136            return Err("ALL privilege cannot be combined with specific privileges".to_string());
2137        }
2138        if !seen_privileges.insert(privilege_key.clone()) {
2139            return Err(format!("duplicate grant/revoke privilege: {privilege_key}"));
2140        }
2141        privileges.push(privilege);
2142    }
2143
2144    if is_revoke {
2145        Ok(Grant::revoke(privileges, obj_str.trim(), role_str.trim()))
2146    } else {
2147        Ok(Grant::new(privileges, obj_str.trim(), role_str.trim()))
2148    }
2149}
2150
2151fn split_grant_subject(after_on: &str, keyword: &str) -> Option<(String, String)> {
2152    let parts: Vec<&str> = after_on.split_whitespace().collect();
2153    let idx = parts
2154        .iter()
2155        .position(|part| part.eq_ignore_ascii_case(keyword))?;
2156    Some((parts[..idx].join(" "), parts[idx + 1..].join(" ")))
2157}
2158
2159fn parse_privilege(raw: &str) -> Result<Privilege, String> {
2160    match raw.trim().to_uppercase().as_str() {
2161        "ALL" => Ok(Privilege::All),
2162        "SELECT" => Ok(Privilege::Select),
2163        "INSERT" => Ok(Privilege::Insert),
2164        "UPDATE" => Ok(Privilege::Update),
2165        "DELETE" => Ok(Privilege::Delete),
2166        "USAGE" => Ok(Privilege::Usage),
2167        "EXECUTE" => Ok(Privilege::Execute),
2168        "" => Err("grant/revoke privilege is empty".to_string()),
2169        other => Err(format!("unknown grant/revoke privilege: {other}")),
2170    }
2171}
2172
2173/// Parse QAIL FK action string to FkAction enum.
2174/// Accepts: cascade, set_null, set_default, restrict, no_action
2175fn parse_fk_action_str(s: &str) -> Result<FkAction, String> {
2176    match s {
2177        "cascade" => Ok(FkAction::Cascade),
2178        "set_null" => Ok(FkAction::SetNull),
2179        "set_default" => Ok(FkAction::SetDefault),
2180        "restrict" => Ok(FkAction::Restrict),
2181        "no_action" => Ok(FkAction::NoAction),
2182        other => Err(format!("unknown foreign key action: {other}")),
2183    }
2184}
2185
2186fn apply_fk_action_options(
2187    mut col: Column,
2188    parts: &[&str],
2189    i: &mut usize,
2190) -> Result<Column, String> {
2191    let mut seen_on_delete = false;
2192    let mut seen_on_update = false;
2193    let mut seen_deferrable = false;
2194    while *i + 1 < parts.len() {
2195        match parts[*i + 1] {
2196            "on_delete" if *i + 2 < parts.len() => {
2197                if seen_on_delete {
2198                    return Err("duplicate on_delete action".to_string());
2199                }
2200                seen_on_delete = true;
2201                let action = parse_fk_action_str(parts[*i + 2])?;
2202                col = col.on_delete(action);
2203                *i += 2;
2204            }
2205            "on_update" if *i + 2 < parts.len() => {
2206                if seen_on_update {
2207                    return Err("duplicate on_update action".to_string());
2208                }
2209                seen_on_update = true;
2210                let action = parse_fk_action_str(parts[*i + 2])?;
2211                col = col.on_update(action);
2212                *i += 2;
2213            }
2214            "on_delete" | "on_update" => {
2215                return Err(format!("{} requires a foreign key action", parts[*i + 1]));
2216            }
2217            "deferrable" => {
2218                if seen_deferrable {
2219                    return Err("duplicate foreign key deferrable option".to_string());
2220                }
2221                seen_deferrable = true;
2222                col = col.deferrable();
2223                *i += 1;
2224            }
2225            "initially_deferred" => {
2226                if seen_deferrable {
2227                    return Err("duplicate foreign key deferrable option".to_string());
2228                }
2229                seen_deferrable = true;
2230                col = col.initially_deferred();
2231                *i += 1;
2232            }
2233            "initially_immediate" => {
2234                if seen_deferrable {
2235                    return Err("duplicate foreign key deferrable option".to_string());
2236                }
2237                seen_deferrable = true;
2238                col = col.initially_immediate();
2239                *i += 1;
2240            }
2241            _ => break,
2242        }
2243    }
2244
2245    Ok(col)
2246}
2247
2248fn parse_fk_reference_target(raw: &str) -> Result<(&str, &str), String> {
2249    let paren_start = raw
2250        .find('(')
2251        .ok_or_else(|| format!("invalid foreign key reference target: {raw}"))?;
2252    let paren_end = raw[paren_start + 1..]
2253        .find(')')
2254        .map(|idx| paren_start + 1 + idx)
2255        .ok_or_else(|| format!("invalid foreign key reference target: {raw}"))?;
2256    if !raw[paren_end + 1..].trim().is_empty() {
2257        return Err(format!(
2258            "trailing content in foreign key reference target: {raw}"
2259        ));
2260    }
2261
2262    let table = raw[..paren_start].trim();
2263    let column = raw[paren_start + 1..paren_end].trim();
2264    if !is_native_table_ref(table) || !is_native_identifier(column) {
2265        return Err(format!("invalid foreign key reference target: {raw}"));
2266    }
2267
2268    Ok((table, column))
2269}
2270
2271fn is_native_table_ref(value: &str) -> bool {
2272    let mut parts = value.split('.');
2273    let Some(first) = parts.next() else {
2274        return false;
2275    };
2276    !first.is_empty() && is_native_identifier(first) && parts.all(is_native_identifier)
2277}
2278
2279fn is_native_column_ref(value: &str) -> bool {
2280    let parts: Vec<&str> = value.split('.').collect();
2281    parts.len() >= 2 && parts.iter().all(|part| is_native_identifier(part))
2282}
2283
2284fn is_native_identifier(value: &str) -> bool {
2285    !value.is_empty()
2286        && value
2287            .chars()
2288            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2289}
2290
2291fn parse_index_method_str(s: &str) -> Result<IndexMethod, String> {
2292    match s.trim().to_ascii_lowercase().as_str() {
2293        "btree" => Ok(IndexMethod::BTree),
2294        "hash" => Ok(IndexMethod::Hash),
2295        "gin" => Ok(IndexMethod::Gin),
2296        "gist" => Ok(IndexMethod::Gist),
2297        "brin" => Ok(IndexMethod::Brin),
2298        "spgist" => Ok(IndexMethod::SpGist),
2299        "hnsw" => Ok(IndexMethod::Hnsw),
2300        "ivfflat" => Ok(IndexMethod::IvfFlat),
2301        "" => Err("index method is empty".to_string()),
2302        other => Err(format!("unknown index method: {other}")),
2303    }
2304}
2305
2306fn is_column_constraint_keyword(token: &str) -> bool {
2307    matches!(
2308        token,
2309        "primary_key"
2310            | "not_null"
2311            | "nullable"
2312            | "unique"
2313            | "default"
2314            | "generated_identity"
2315            | "generated_by_default_identity"
2316            | "references"
2317            | "on_delete"
2318            | "on_update"
2319            | "deferrable"
2320            | "initially_deferred"
2321            | "initially_immediate"
2322            | "check_name"
2323    ) || token.starts_with("check(")
2324        || token.starts_with("generated_stored(")
2325}
2326
2327/// Parse a QAIL check expression string into a CheckExpr.
2328/// Supports:
2329///   "col >= 0"           → GreaterOrEqual
2330///   "col > 0"            → GreaterThan
2331///   "col <= 100"         → LessOrEqual
2332///   "col < 100"          → LessThan
2333///   "col between 0 200"  → Between
2334///   "col >= 0 and col <= 200" → And(GreaterOrEqual, LessOrEqual)
2335pub fn parse_check_expr_fragment(s: &str) -> Option<CheckExpr> {
2336    parse_check_expr_from_qail(s)
2337}
2338
2339fn parse_check_expr_from_qail(s: &str) -> Option<CheckExpr> {
2340    let s = strip_wrapping_check_parens(s.trim()).trim();
2341
2342    // Try "col between low high"
2343    let parts: Vec<&str> = s.split_whitespace().collect();
2344    if parts.len() == 4 && parts[1] == "between" {
2345        let col = parts[0].to_string();
2346        let low = parts[2].parse::<i64>().ok()?;
2347        let high = parts[3].parse::<i64>().ok()?;
2348        return Some(CheckExpr::Between {
2349            column: col,
2350            low,
2351            high,
2352        });
2353    }
2354
2355    // Try "col in [a, b, \"c,d\"]"
2356    if let Some(expr) = parse_check_in_expr(s) {
2357        return Some(expr);
2358    }
2359
2360    // Try Postgres introspection form:
2361    //   (col)::text = ANY (ARRAY[('a'::varchar)::text, ('b'::varchar)::text])
2362    if let Some(expr) = parse_postgres_any_array_check_expr(s) {
2363        return Some(expr);
2364    }
2365
2366    // Try Postgres regex check form: (col)::text ~ 'pattern'::text
2367    if let Some(expr) = parse_postgres_regex_check_expr(s) {
2368        return Some(expr);
2369    }
2370
2371    // Try production-safe Postgres forms:
2372    //   col <> 'literal'::text
2373    //   col <= COALESCE(other_col, 'literal'::date)
2374    //   (col)::text = lower(btrim((col)::text))
2375    if let Some(expr) = parse_postgres_text_compare_check_expr(s) {
2376        return Some(expr);
2377    }
2378    if let Some(expr) = parse_postgres_coalesce_compare_check_expr(s) {
2379        return Some(expr);
2380    }
2381    if let Some(expr) = parse_postgres_lower_trim_check_expr(s) {
2382        return Some(expr);
2383    }
2384
2385    // Try "left and right"
2386    if let Some(and_pos) = find_top_level_operator(s, " and ") {
2387        let left = parse_check_expr_from_qail(&s[..and_pos])?;
2388        let right = parse_check_expr_from_qail(&s[and_pos + 5..])?;
2389        return Some(CheckExpr::And(Box::new(left), Box::new(right)));
2390    }
2391
2392    // Try "left or right"
2393    if let Some(or_pos) = find_top_level_operator(s, " or ") {
2394        let left = parse_check_expr_from_qail(&s[..or_pos])?;
2395        let right = parse_check_expr_from_qail(&s[or_pos + 4..])?;
2396        return Some(CheckExpr::Or(Box::new(left), Box::new(right)));
2397    }
2398
2399    // Try simple comparisons: "col >= val", "col > val", etc.
2400    type CheckExprConstructor = fn(String, i64) -> CheckExpr;
2401    let ops: &[(&str, CheckExprConstructor)] = &[
2402        (">=", |col, val| CheckExpr::GreaterOrEqual {
2403            column: col,
2404            value: val,
2405        }),
2406        ("<=", |col, val| CheckExpr::LessOrEqual {
2407            column: col,
2408            value: val,
2409        }),
2410        (">", |col, val| CheckExpr::GreaterThan {
2411            column: col,
2412            value: val,
2413        }),
2414        ("<", |col, val| CheckExpr::LessThan {
2415            column: col,
2416            value: val,
2417        }),
2418    ];
2419
2420    for (op, constructor) in ops {
2421        if let Some(pos) = s.find(op) {
2422            let Some(col) = parse_postgres_any_check_column(&s[..pos]) else {
2423                continue;
2424            };
2425            let Some(val) = parse_check_integer_literal(&s[pos + op.len()..]) else {
2426                continue;
2427            };
2428            return Some(constructor(col, val));
2429        }
2430    }
2431
2432    // Try simple column-to-column comparisons:
2433    //   origin_harbor_id <> destination_harbor_id
2434    //   (start_time)::time without time zone < (end_time)::time without time zone
2435    if let Some(expr) = parse_column_comparison_check_expr(s) {
2436        return Some(expr);
2437    }
2438
2439    // Try "length(col) >= min" / "length(col) <= max"
2440    if s.starts_with("length(") {
2441        let inner_end = s.find(')')?;
2442        let col = s[7..inner_end].to_string();
2443        let rest = s[inner_end + 1..].trim();
2444        if let Some(val_str) = rest.strip_prefix(">=") {
2445            let min = val_str.trim().parse::<usize>().ok()?;
2446            return Some(CheckExpr::MinLength { column: col, min });
2447        }
2448        if let Some(val_str) = rest.strip_prefix("<=") {
2449            let max = val_str.trim().parse::<usize>().ok()?;
2450            return Some(CheckExpr::MaxLength { column: col, max });
2451        }
2452    }
2453
2454    // Try "col not_null"
2455    if parts.len() == 2 && parts[1] == "not_null" {
2456        return Some(CheckExpr::NotNull {
2457            column: parts[0].to_string(),
2458        });
2459    }
2460
2461    if s.is_empty() {
2462        None
2463    } else {
2464        Some(CheckExpr::Sql(s.to_string()))
2465    }
2466}
2467
2468fn strip_wrapping_check_parens(mut s: &str) -> &str {
2469    loop {
2470        let trimmed = s.trim();
2471        if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
2472            return trimmed;
2473        }
2474
2475        let Some(close) = find_matching_paren(trimmed, 0) else {
2476            return trimmed;
2477        };
2478        if close != trimmed.len() - 1 {
2479            return trimmed;
2480        }
2481        s = &trimmed[1..close];
2482    }
2483}
2484
2485fn parse_check_integer_literal(raw: &str) -> Option<i64> {
2486    let mut value = raw.trim();
2487    if let Some(cast_pos) = find_top_level_type_cast(value) {
2488        value = value[..cast_pos].trim();
2489    }
2490    value = strip_wrapping_check_parens(value).trim();
2491    if let Some(cast_pos) = find_top_level_type_cast(value) {
2492        value = value[..cast_pos].trim();
2493        value = strip_wrapping_check_parens(value).trim();
2494    }
2495
2496    value.parse::<i64>().ok().or_else(|| {
2497        let parsed = value.parse::<f64>().ok()?;
2498        if parsed.is_finite()
2499            && parsed.fract() == 0.0
2500            && parsed >= i64::MIN as f64
2501            && parsed <= i64::MAX as f64
2502        {
2503            Some(parsed as i64)
2504        } else {
2505            None
2506        }
2507    })
2508}
2509
2510fn find_top_level_type_cast(s: &str) -> Option<usize> {
2511    let mut quote: Option<char> = None;
2512    let mut paren_depth = 0usize;
2513    let mut bracket_depth = 0usize;
2514    let mut chars = s.char_indices().peekable();
2515
2516    while let Some((idx, ch)) = chars.next() {
2517        if let Some(q) = quote {
2518            if ch == q {
2519                if chars.peek().is_some_and(|(_, next)| *next == q) {
2520                    chars.next();
2521                } else {
2522                    quote = None;
2523                }
2524            }
2525            continue;
2526        }
2527
2528        match ch {
2529            '\'' | '"' => quote = Some(ch),
2530            '(' => paren_depth += 1,
2531            ')' => paren_depth = paren_depth.saturating_sub(1),
2532            '[' => bracket_depth += 1,
2533            ']' => bracket_depth = bracket_depth.saturating_sub(1),
2534            ':' if paren_depth == 0 && bracket_depth == 0 && s[idx..].starts_with("::") => {
2535                return Some(idx);
2536            }
2537            _ => {}
2538        }
2539    }
2540
2541    None
2542}
2543
2544fn parse_postgres_any_array_check_expr(s: &str) -> Option<CheckExpr> {
2545    let eq_pos = find_top_level_equality(s)?;
2546    let column = parse_postgres_any_check_column(&s[..eq_pos])?;
2547    let raw_values = &s[eq_pos + 1..];
2548
2549    if let Some(values) = parse_postgres_any_text_array_values(raw_values)
2550        && !values.is_empty()
2551    {
2552        return Some(CheckExpr::In { column, values });
2553    }
2554
2555    if let Some(values) = parse_postgres_any_integer_array_values(raw_values)
2556        && !values.is_empty()
2557    {
2558        return Some(CheckExpr::InIntegers { column, values });
2559    }
2560
2561    None
2562}
2563
2564fn parse_postgres_regex_check_expr(s: &str) -> Option<CheckExpr> {
2565    let regex_pos = find_top_level_regex_operator(s)?;
2566    let column = parse_postgres_any_check_column(&s[..regex_pos])?;
2567    let pattern = parse_postgres_text_literal(&s[regex_pos + 1..])?;
2568    Some(CheckExpr::Regex { column, pattern })
2569}
2570
2571fn parse_postgres_text_compare_check_expr(s: &str) -> Option<CheckExpr> {
2572    let (op_pos, op, op_len) = find_top_level_comparison_operator(s)?;
2573    if !matches!(op, CheckComparisonOp::Equal | CheckComparisonOp::NotEqual) {
2574        return None;
2575    }
2576    let column = parse_postgres_any_check_column(&s[..op_pos])?;
2577    let value = parse_postgres_text_literal(&s[op_pos + op_len..])?;
2578    Some(CheckExpr::TextCompare { column, op, value })
2579}
2580
2581fn parse_postgres_coalesce_compare_check_expr(s: &str) -> Option<CheckExpr> {
2582    let (op_pos, op, op_len) = find_top_level_comparison_operator(s)?;
2583    let left_column = parse_postgres_any_check_column(&s[..op_pos])?;
2584    let (coalesce_column, fallback, fallback_cast) =
2585        parse_postgres_coalesce_check_rhs(&s[op_pos + op_len..])?;
2586    Some(CheckExpr::CompareColumnToCoalesce {
2587        left_column,
2588        op,
2589        coalesce_column,
2590        fallback,
2591        fallback_cast,
2592    })
2593}
2594
2595fn parse_postgres_lower_trim_check_expr(s: &str) -> Option<CheckExpr> {
2596    let eq_pos = find_top_level_equality(s)?;
2597    let column = parse_postgres_any_check_column(&s[..eq_pos])?;
2598    let rhs_column = parse_lower_btrim_column(&s[eq_pos + 1..])?;
2599    if rhs_column == column {
2600        Some(CheckExpr::LowerTrimEquals { column })
2601    } else {
2602        None
2603    }
2604}
2605
2606fn find_top_level_equality(s: &str) -> Option<usize> {
2607    let mut quote: Option<char> = None;
2608    let mut paren_depth = 0usize;
2609    let mut bracket_depth = 0usize;
2610    let mut chars = s.char_indices().peekable();
2611
2612    while let Some((idx, ch)) = chars.next() {
2613        if let Some(q) = quote {
2614            if ch == q {
2615                if chars.peek().is_some_and(|(_, next)| *next == q) {
2616                    chars.next();
2617                } else {
2618                    quote = None;
2619                }
2620            }
2621            continue;
2622        }
2623
2624        match ch {
2625            '\'' | '"' => quote = Some(ch),
2626            '(' => paren_depth += 1,
2627            ')' => paren_depth = paren_depth.saturating_sub(1),
2628            '[' => bracket_depth += 1,
2629            ']' => bracket_depth = bracket_depth.saturating_sub(1),
2630            '=' if paren_depth == 0 && bracket_depth == 0 => {
2631                let before = s[..idx].chars().next_back();
2632                let after = s[idx + ch.len_utf8()..].chars().next();
2633                if !matches!(before, Some('>' | '<' | '!' | '=')) && !matches!(after, Some('=')) {
2634                    return Some(idx);
2635                }
2636            }
2637            _ => {}
2638        }
2639    }
2640
2641    None
2642}
2643
2644fn find_top_level_regex_operator(s: &str) -> Option<usize> {
2645    let mut quote: Option<char> = None;
2646    let mut paren_depth = 0usize;
2647    let mut bracket_depth = 0usize;
2648    let mut chars = s.char_indices().peekable();
2649
2650    while let Some((idx, ch)) = chars.next() {
2651        if let Some(q) = quote {
2652            if ch == q {
2653                if chars.peek().is_some_and(|(_, next)| *next == q) {
2654                    chars.next();
2655                } else {
2656                    quote = None;
2657                }
2658            }
2659            continue;
2660        }
2661
2662        match ch {
2663            '\'' | '"' => quote = Some(ch),
2664            '(' => paren_depth += 1,
2665            ')' => paren_depth = paren_depth.saturating_sub(1),
2666            '[' => bracket_depth += 1,
2667            ']' => bracket_depth = bracket_depth.saturating_sub(1),
2668            '~' if paren_depth == 0 && bracket_depth == 0 => {
2669                let before = s[..idx].chars().next_back();
2670                if !matches!(before, Some('!')) {
2671                    return Some(idx);
2672                }
2673            }
2674            _ => {}
2675        }
2676    }
2677
2678    None
2679}
2680
2681fn parse_column_comparison_check_expr(s: &str) -> Option<CheckExpr> {
2682    let (op_pos, op, op_len) = find_top_level_comparison_operator(s)?;
2683    let left_column = parse_postgres_any_check_column(&s[..op_pos])?;
2684    let right_column = parse_postgres_any_check_column(&s[op_pos + op_len..])?;
2685    Some(CheckExpr::CompareColumns {
2686        left_column,
2687        op,
2688        right_column,
2689    })
2690}
2691
2692fn find_top_level_comparison_operator(s: &str) -> Option<(usize, CheckComparisonOp, usize)> {
2693    let mut quote: Option<char> = None;
2694    let mut paren_depth = 0usize;
2695    let mut bracket_depth = 0usize;
2696    let mut chars = s.char_indices().peekable();
2697
2698    while let Some((idx, ch)) = chars.next() {
2699        if let Some(q) = quote {
2700            if ch == q {
2701                if chars.peek().is_some_and(|(_, next)| *next == q) {
2702                    chars.next();
2703                } else {
2704                    quote = None;
2705                }
2706            }
2707            continue;
2708        }
2709
2710        match ch {
2711            '\'' | '"' => quote = Some(ch),
2712            '(' => paren_depth += 1,
2713            ')' => paren_depth = paren_depth.saturating_sub(1),
2714            '[' => bracket_depth += 1,
2715            ']' => bracket_depth = bracket_depth.saturating_sub(1),
2716            '<' if paren_depth == 0 && bracket_depth == 0 => {
2717                if s[idx..].starts_with("<=") {
2718                    return Some((idx, CheckComparisonOp::LessOrEqual, 2));
2719                }
2720                if s[idx..].starts_with("<>") {
2721                    return Some((idx, CheckComparisonOp::NotEqual, 2));
2722                }
2723                return Some((idx, CheckComparisonOp::LessThan, 1));
2724            }
2725            '>' if paren_depth == 0 && bracket_depth == 0 => {
2726                if s[idx..].starts_with(">=") {
2727                    return Some((idx, CheckComparisonOp::GreaterOrEqual, 2));
2728                }
2729                return Some((idx, CheckComparisonOp::GreaterThan, 1));
2730            }
2731            '!' if paren_depth == 0 && bracket_depth == 0 && s[idx..].starts_with("!=") => {
2732                return Some((idx, CheckComparisonOp::NotEqual, 2));
2733            }
2734            '=' if paren_depth == 0 && bracket_depth == 0 => {
2735                return Some((idx, CheckComparisonOp::Equal, 1));
2736            }
2737            _ => {}
2738        }
2739    }
2740
2741    None
2742}
2743
2744fn parse_postgres_any_check_column(raw: &str) -> Option<String> {
2745    let column = strip_postgres_type_casts(raw);
2746    if is_native_identifier(column) {
2747        Some(column.to_string())
2748    } else {
2749        None
2750    }
2751}
2752
2753fn parse_postgres_coalesce_check_rhs(raw: &str) -> Option<(String, String, Option<String>)> {
2754    let raw = strip_wrapping_check_parens(raw.trim()).trim();
2755    let args = parse_single_function_call_args(raw, "COALESCE")?;
2756    if args.len() != 2 {
2757        return None;
2758    }
2759    let column = parse_postgres_any_check_column(&args[0])?;
2760    let (fallback, fallback_cast) = parse_postgres_text_literal_with_cast(&args[1])?;
2761    Some((column, fallback, fallback_cast))
2762}
2763
2764fn parse_lower_btrim_column(raw: &str) -> Option<String> {
2765    let lower_args = parse_single_function_call_args(raw, "lower")?;
2766    if lower_args.len() != 1 {
2767        return None;
2768    }
2769    let btrim_args = parse_single_function_call_args(&lower_args[0], "btrim")?;
2770    if btrim_args.len() != 1 {
2771        return None;
2772    }
2773    parse_postgres_any_check_column(&btrim_args[0])
2774}
2775
2776fn parse_single_function_call_args(raw: &str, name: &str) -> Option<Vec<String>> {
2777    let raw = strip_wrapping_check_parens(raw.trim()).trim();
2778    let args = strip_case_insensitive_prefix(raw, name)?.trim_start();
2779    if !args.starts_with('(') {
2780        return None;
2781    }
2782    let close = find_matching_paren(args, 0)?;
2783    if !args[close + 1..].trim().is_empty() {
2784        return None;
2785    }
2786    split_function_args(&args[1..close]).ok()
2787}
2788
2789fn strip_postgres_type_casts(mut value: &str) -> &str {
2790    loop {
2791        let trimmed = strip_wrapping_check_parens(value.trim()).trim();
2792        let without_cast = find_top_level_type_cast(trimmed)
2793            .map(|cast_pos| trimmed[..cast_pos].trim())
2794            .unwrap_or(trimmed);
2795        let unwrapped = strip_wrapping_check_parens(without_cast).trim();
2796        if unwrapped.len() == value.trim().len() {
2797            return unwrapped;
2798        }
2799        value = unwrapped;
2800    }
2801}
2802
2803fn parse_postgres_any_text_array_values(raw: &str) -> Option<Vec<String>> {
2804    let raw = raw.trim();
2805    let any_args = strip_case_insensitive_prefix(raw, "ANY")?.trim_start();
2806    let open = any_args.find('(')?;
2807    if !any_args[..open].trim().is_empty() {
2808        return None;
2809    }
2810    let close = find_matching_paren(any_args, open)?;
2811    if !any_args[close + 1..].trim().is_empty() {
2812        return None;
2813    }
2814
2815    let array_expr = strip_postgres_type_casts(&any_args[open + 1..close]);
2816    let after_array = strip_case_insensitive_prefix(array_expr, "ARRAY")?.trim_start();
2817    if !after_array.starts_with('[') {
2818        return None;
2819    }
2820
2821    let body = list_body_before_closing_bracket(&after_array[1..])?;
2822    let mut values = Vec::new();
2823    for item in split_top_level_csv(body).ok()? {
2824        values.push(parse_postgres_text_array_item(&item)?);
2825    }
2826
2827    let mut seen = HashSet::new();
2828    if values.iter().any(|value| !seen.insert(value)) {
2829        return None;
2830    }
2831
2832    Some(values)
2833}
2834
2835fn parse_postgres_any_integer_array_values(raw: &str) -> Option<Vec<i64>> {
2836    let raw = raw.trim();
2837    let any_args = strip_case_insensitive_prefix(raw, "ANY")?.trim_start();
2838    let open = any_args.find('(')?;
2839    if !any_args[..open].trim().is_empty() {
2840        return None;
2841    }
2842    let close = find_matching_paren(any_args, open)?;
2843    if !any_args[close + 1..].trim().is_empty() {
2844        return None;
2845    }
2846
2847    let array_expr = strip_postgres_type_casts(&any_args[open + 1..close]);
2848    let after_array = strip_case_insensitive_prefix(array_expr, "ARRAY")?.trim_start();
2849    if !after_array.starts_with('[') {
2850        return None;
2851    }
2852
2853    let body = list_body_before_closing_bracket(&after_array[1..])?;
2854    let mut values = Vec::new();
2855    for item in split_top_level_csv(body).ok()? {
2856        values.push(parse_check_integer_literal(&item)?);
2857    }
2858
2859    let mut seen = HashSet::new();
2860    if values.iter().any(|value| !seen.insert(*value)) {
2861        return None;
2862    }
2863
2864    Some(values)
2865}
2866
2867fn parse_postgres_text_array_item(raw: &str) -> Option<String> {
2868    parse_postgres_text_literal(raw)
2869}
2870
2871fn parse_postgres_text_literal(raw: &str) -> Option<String> {
2872    parse_postgres_text_literal_with_cast(raw).map(|(value, _)| value)
2873}
2874
2875fn parse_postgres_text_literal_with_cast(raw: &str) -> Option<(String, Option<String>)> {
2876    let raw = raw.trim();
2877    let (value, cast) = if let Some(cast_pos) = find_top_level_type_cast(raw) {
2878        let value = strip_wrapping_check_parens(raw[..cast_pos].trim()).trim();
2879        let cast = raw[cast_pos + 2..].trim();
2880        if cast.is_empty() || !is_safe_postgres_type_cast(cast) {
2881            return None;
2882        }
2883        (value, Some(cast.to_string()))
2884    } else {
2885        (raw, None)
2886    };
2887
2888    let value = strip_postgres_type_casts(value);
2889    if !value.starts_with('\'') {
2890        return None;
2891    }
2892    let parsed = parse_enum_value(value).ok()?;
2893    Some((parsed, cast))
2894}
2895
2896fn is_safe_postgres_type_cast(cast: &str) -> bool {
2897    !cast.is_empty()
2898        && cast
2899            .chars()
2900            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ' '))
2901}
2902
2903fn strip_case_insensitive_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
2904    let head = value.get(..prefix.len())?;
2905    head.eq_ignore_ascii_case(prefix)
2906        .then_some(&value[prefix.len()..])
2907}
2908
2909fn find_top_level_operator(s: &str, operator: &str) -> Option<usize> {
2910    let mut quote: Option<char> = None;
2911    let mut paren_depth = 0usize;
2912    let mut bracket_depth = 0usize;
2913    let mut chars = s.char_indices().peekable();
2914
2915    while let Some((idx, ch)) = chars.next() {
2916        if let Some(q) = quote {
2917            if ch == q {
2918                if chars.peek().is_some_and(|(_, next)| *next == q) {
2919                    chars.next();
2920                } else {
2921                    quote = None;
2922                }
2923            }
2924            continue;
2925        }
2926
2927        match ch {
2928            '\'' | '"' => quote = Some(ch),
2929            '(' => paren_depth += 1,
2930            ')' => paren_depth = paren_depth.saturating_sub(1),
2931            '[' => bracket_depth += 1,
2932            ']' => bracket_depth = bracket_depth.saturating_sub(1),
2933            _ => {
2934                if paren_depth == 0
2935                    && bracket_depth == 0
2936                    && s.get(idx..idx + operator.len())
2937                        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(operator))
2938                {
2939                    return Some(idx);
2940                }
2941            }
2942        }
2943    }
2944
2945    None
2946}
2947
2948fn parse_check_in_expr(s: &str) -> Option<CheckExpr> {
2949    let marker = " in [";
2950    let pos = s.find(marker)?;
2951    let column = s[..pos].trim();
2952    if column.is_empty() {
2953        return None;
2954    }
2955
2956    let values_start = pos + marker.len();
2957    let values_raw = list_body_before_closing_bracket(&s[values_start..])?;
2958    let values = parse_enum_values(values_raw).ok()?;
2959    if values.is_empty() {
2960        return None;
2961    }
2962
2963    Some(CheckExpr::In {
2964        column: column.to_string(),
2965        values,
2966    })
2967}
2968
2969fn list_body_before_closing_bracket(raw: &str) -> Option<&str> {
2970    let mut quote: Option<char> = None;
2971    let mut chars = raw.char_indices().peekable();
2972
2973    while let Some((idx, ch)) = chars.next() {
2974        if let Some(q) = quote {
2975            if ch == q {
2976                if chars.peek().is_some_and(|(_, next)| *next == q) {
2977                    chars.next();
2978                } else {
2979                    quote = None;
2980                }
2981            }
2982            continue;
2983        }
2984
2985        match ch {
2986            '\'' | '"' => quote = Some(ch),
2987            ']' => {
2988                if raw[idx + ch.len_utf8()..].trim().is_empty() {
2989                    return Some(&raw[..idx]);
2990                }
2991                return None;
2992            }
2993            _ => {}
2994        }
2995    }
2996
2997    None
2998}
2999
3000/// Parse an infrastructure resource declaration.
3001/// Supports single-line: `bucket avatars`
3002/// and multi-line block: `bucket avatars { provider s3 region "ap-southeast-1" }`
3003fn parse_resource<'a, I: Iterator<Item = &'a str>>(
3004    first_line: &str,
3005    lines: &mut std::iter::Peekable<I>,
3006    kind: ResourceKind,
3007) -> Result<ResourceDef, String> {
3008    let keyword = kind.to_string();
3009    let after_keyword = first_line
3010        .strip_prefix(&keyword)
3011        .ok_or_else(|| format!("Expected '{}' keyword", keyword))?
3012        .trim();
3013
3014    // Extract name (first word after the keyword)
3015    let (name, rest) = match after_keyword.split_once(|c: char| c.is_whitespace() || c == '{') {
3016        Some((n, r)) => (n.trim(), r.trim()),
3017        None => (after_keyword.trim_end_matches('{'), ""),
3018    };
3019
3020    if name.is_empty() {
3021        return Err(format!("Missing name for {} declaration", keyword));
3022    }
3023
3024    let mut provider = None;
3025    let mut properties = HashMap::new();
3026
3027    // Check if block is on the same line: `bucket avatars { provider s3 }`
3028    let has_block = first_line.contains('{');
3029    if !has_block && !rest.is_empty() {
3030        return Err(format!("Trailing content after {} resource name", keyword));
3031    }
3032
3033    if has_block {
3034        // Collect content until closing brace
3035        let mut block_content = rest.trim_start_matches('{').to_string();
3036        let mut found_closing_brace = false;
3037        let mut content = String::new();
3038
3039        // If no closing brace on same line, read until we find it
3040        if let Some(closed_content) = resource_block_content_before_closing(&block_content)? {
3041            found_closing_brace = true;
3042            content = closed_content;
3043        } else {
3044            for next_line in lines.by_ref() {
3045                let next_line = next_line.trim();
3046                if is_blank_or_qail_comment(next_line) {
3047                    continue;
3048                }
3049                block_content.push(' ');
3050                block_content.push_str(next_line);
3051                if let Some(closed_content) = resource_block_content_before_closing(&block_content)?
3052                {
3053                    found_closing_brace = true;
3054                    content = closed_content;
3055                    break;
3056                }
3057            }
3058        }
3059        if !found_closing_brace {
3060            return Err(format!("Unclosed {} resource block '{}'", keyword, name));
3061        }
3062
3063        // Parse key-value pairs from block content
3064        let content = content.trim();
3065        let tokens = split_resource_tokens(content)?;
3066        let mut tokens = tokens.iter();
3067        let mut seen_keys = HashSet::new();
3068
3069        while let Some(key) = tokens.next() {
3070            if key.is_empty() || key == "}" {
3071                continue;
3072            }
3073            if !seen_keys.insert(key) {
3074                return Err(format!(
3075                    "Duplicate resource property '{}' in '{}'",
3076                    key, name
3077                ));
3078            }
3079            if let Some(value) = tokens.next() {
3080                if key == "provider" {
3081                    provider = Some(value.to_string());
3082                } else {
3083                    properties.insert(key.to_string(), value.to_string());
3084                }
3085            } else {
3086                return Err(format!(
3087                    "Resource property '{}' in '{}' requires a value",
3088                    key, name
3089                ));
3090            }
3091        }
3092    }
3093
3094    Ok(ResourceDef {
3095        name: name.to_string(),
3096        kind,
3097        provider,
3098        properties,
3099    })
3100}
3101
3102fn resource_block_content_before_closing(content: &str) -> Result<Option<String>, String> {
3103    let mut quote: Option<char> = None;
3104    let mut escaped = false;
3105
3106    for (idx, ch) in content.char_indices() {
3107        if escaped {
3108            escaped = false;
3109            continue;
3110        }
3111
3112        match quote {
3113            Some(q) => match ch {
3114                '\\' => escaped = true,
3115                c if c == q => quote = None,
3116                _ => {}
3117            },
3118            None => match ch {
3119                '"' | '\'' => quote = Some(ch),
3120                '}' => {
3121                    let rest = &content[idx + ch.len_utf8()..];
3122                    if !rest.trim().is_empty() {
3123                        return Err("Trailing content after resource block".to_string());
3124                    }
3125                    return Ok(Some(content[..idx].trim().to_string()));
3126                }
3127                _ => {}
3128            },
3129        }
3130    }
3131
3132    Ok(None)
3133}
3134
3135fn split_resource_tokens(content: &str) -> Result<Vec<String>, String> {
3136    let mut tokens = Vec::new();
3137    let mut current = String::new();
3138    let mut quote: Option<char> = None;
3139    let mut escaped = false;
3140
3141    for ch in content.chars() {
3142        if escaped {
3143            current.push(ch);
3144            escaped = false;
3145            continue;
3146        }
3147
3148        match quote {
3149            Some(q) => match ch {
3150                '\\' => escaped = true,
3151                c if c == q => quote = None,
3152                c => current.push(c),
3153            },
3154            None => match ch {
3155                '"' | '\'' => quote = Some(ch),
3156                c if c.is_whitespace() => {
3157                    if !current.is_empty() {
3158                        tokens.push(std::mem::take(&mut current));
3159                    }
3160                }
3161                c => current.push(c),
3162            },
3163        }
3164    }
3165
3166    if escaped {
3167        current.push('\\');
3168    }
3169    if quote.is_some() {
3170        return Err("Unterminated quoted resource value".to_string());
3171    }
3172    if !current.is_empty() {
3173        tokens.push(current);
3174    }
3175
3176    Ok(tokens)
3177}
3178
3179/// Parse an RLS policy definition.
3180///
3181/// Syntax:
3182/// ```text
3183/// policy NAME on TABLE [for TARGET] [to ROLE] [restrictive|permissive]
3184///   using $$ EXPR $$
3185///   with_check $$ EXPR $$
3186/// ```
3187///
3188/// Both `using` and `with_check` are optional. The `$$` delimiters may span
3189/// multiple lines (same pattern as views / functions).
3190fn parse_policy<'a, I: Iterator<Item = &'a str>>(
3191    first_line: &str,
3192    lines: &mut std::iter::Peekable<I>,
3193) -> Result<RlsPolicy, String> {
3194    // Parse header: "policy NAME on TABLE [for TARGET] [to ROLE] [restrictive|permissive]"
3195    let rest = first_line
3196        .strip_prefix("policy ")
3197        .ok_or("Expected 'policy' prefix")?
3198        .trim();
3199    let parts: Vec<&str> = rest.split_whitespace().collect();
3200
3201    // Minimum: NAME on TABLE
3202    if parts.len() < 3 {
3203        return Err(format!("Invalid policy: {}", first_line));
3204    }
3205
3206    let name = parts[0];
3207
3208    let on_idx = parts
3209        .iter()
3210        .position(|&p| p == "on")
3211        .ok_or_else(|| format!("policy missing 'on' keyword: {}", first_line))?;
3212    let table = parts
3213        .get(on_idx + 1)
3214        .ok_or_else(|| format!("policy missing table name: {}", first_line))?;
3215
3216    let mut policy = RlsPolicy::create(name, *table);
3217    parse_policy_clause_tokens(&parts[on_idx + 2..], &mut policy, first_line)?;
3218
3219    // Consume indented continuation lines (using / with_check)
3220    while let Some(&next_line) = lines.peek() {
3221        let trimmed = next_line.trim();
3222        if is_blank_or_qail_comment(trimmed) {
3223            lines.next();
3224            continue;
3225        }
3226        // Only continue if the line is indented (part of this policy block)
3227        if !next_line.starts_with("  ") && !next_line.starts_with('\t') {
3228            break;
3229        }
3230
3231        // Consume the peeked line before processing it
3232        lines.next();
3233
3234        if is_policy_header_clause(trimmed) {
3235            let clause_parts: Vec<&str> = trimmed.split_whitespace().collect();
3236            parse_policy_clause_tokens(&clause_parts, &mut policy, trimmed)?;
3237        } else if trimmed.starts_with("using ") || trimmed.starts_with("with_check ") {
3238            let is_using = trimmed.starts_with("using ");
3239            let keyword = if is_using { "using " } else { "with_check " };
3240            let after_keyword = trimmed.strip_prefix(keyword).unwrap_or("").trim();
3241
3242            let body = extract_dollar_body(after_keyword, lines)?;
3243            // Preserve policy predicate text as-is. Parsing/re-serialization can
3244            // alter semantics for complex predicates.
3245            let expr = Expr::Named(body.clone());
3246
3247            if is_using {
3248                policy.using = Some(expr);
3249            } else {
3250                policy.with_check = Some(expr);
3251            }
3252        } else {
3253            return Err(format!("Unknown policy continuation line: {}", trimmed));
3254        }
3255    }
3256
3257    Ok(policy)
3258}
3259
3260fn is_policy_header_clause(trimmed: &str) -> bool {
3261    let first = trimmed.split_whitespace().next().unwrap_or("");
3262    matches!(
3263        first.to_ascii_lowercase().as_str(),
3264        "for" | "to" | "restrictive" | "permissive"
3265    )
3266}
3267
3268fn parse_policy_clause_tokens(
3269    parts: &[&str],
3270    policy: &mut RlsPolicy,
3271    source: &str,
3272) -> Result<(), String> {
3273    let mut idx = 0;
3274    while idx < parts.len() {
3275        match parts[idx].to_ascii_lowercase().as_str() {
3276            "for" => {
3277                idx += 1;
3278                let target_str = parts
3279                    .get(idx)
3280                    .ok_or_else(|| format!("policy missing target: {}", source))?;
3281                policy.target = parse_policy_target(target_str)?;
3282                idx += 1;
3283            }
3284            "to" => {
3285                idx += 1;
3286                let role = parts
3287                    .get(idx)
3288                    .ok_or_else(|| format!("policy missing role after 'to': {}", source))?;
3289                policy.role = Some((*role).to_string());
3290                idx += 1;
3291            }
3292            "restrictive" => {
3293                policy.permissiveness = PolicyPermissiveness::Restrictive;
3294                idx += 1;
3295            }
3296            "permissive" => {
3297                policy.permissiveness = PolicyPermissiveness::Permissive;
3298                idx += 1;
3299            }
3300            _ => {
3301                return Err(format!(
3302                    "Unknown policy clause '{}': {}",
3303                    parts[idx], source
3304                ));
3305            }
3306        }
3307    }
3308
3309    Ok(())
3310}
3311
3312fn parse_policy_target(target_str: &str) -> Result<PolicyTarget, String> {
3313    match target_str.to_lowercase().as_str() {
3314        "all" => Ok(PolicyTarget::All),
3315        "select" => Ok(PolicyTarget::Select),
3316        "insert" => Ok(PolicyTarget::Insert),
3317        "update" => Ok(PolicyTarget::Update),
3318        "delete" => Ok(PolicyTarget::Delete),
3319        _ => Err(format!("Unknown policy target: {}", target_str)),
3320    }
3321}
3322
3323/// Extract text between `$$` markers, consuming continuation lines if needed.
3324fn extract_dollar_body<'a, I: Iterator<Item = &'a str>>(
3325    first_part: &str,
3326    lines: &mut std::iter::Peekable<I>,
3327) -> Result<String, String> {
3328    // Strip leading $$
3329    let after_open = first_part
3330        .strip_prefix("$$")
3331        .ok_or("expected $$ to start expression")?
3332        .trim_start();
3333
3334    if let Some(pos) = after_open.find("$$") {
3335        // Single-line: $$ body $$
3336        Ok(after_open[..pos].trim().to_string())
3337    } else {
3338        // Multi-line: collect until closing $$
3339        let mut body = after_open.to_string();
3340        for line in lines.by_ref() {
3341            if let Some(pos) = line.find("$$") {
3342                let before = &line[..pos];
3343                if !body.is_empty() {
3344                    body.push('\n');
3345                }
3346                body.push_str(before);
3347                break;
3348            }
3349            body.push('\n');
3350            body.push_str(line);
3351        }
3352        Ok(body.trim().to_string())
3353    }
3354}
3355#[cfg(test)]
3356mod tests {
3357    use super::super::schema::GrantAction;
3358    use super::*;
3359
3360    #[test]
3361    fn test_parse_simple_table() {
3362        let input = r#"
3363table users {
3364  id serial primary_key
3365  name text not_null
3366  email text nullable unique
3367}
3368"#;
3369        let schema = parse_qail(input).unwrap();
3370        assert!(schema.tables.contains_key("users"));
3371        let table = &schema.tables["users"];
3372        assert_eq!(table.columns.len(), 3);
3373        assert!(table.columns[0].primary_key);
3374        assert!(!table.columns[1].nullable);
3375        assert!(table.columns[2].unique);
3376    }
3377
3378    #[test]
3379    fn test_parse_table_rejects_malformed_braces() {
3380        let cases = [
3381            ("table users\n  id serial primary_key\n}", "opening"),
3382            (
3383                "table users {\n  id serial primary_key",
3384                "Unclosed table definition 'users'",
3385            ),
3386            (
3387                "table users { id serial primary_key }",
3388                "trailing content after table opening brace",
3389            ),
3390            (
3391                "table users {\n  id serial primary_key\n} trailing",
3392                "trailing content after table closing brace",
3393            ),
3394        ];
3395
3396        for (input, expected) in cases {
3397            let err = parse_qail(input).expect_err("malformed table braces should fail");
3398            assert!(err.contains(expected), "expected '{expected}' in '{err}'");
3399        }
3400    }
3401
3402    #[test]
3403    fn test_parse_table_rejects_duplicate_columns() {
3404        let input = r#"
3405table users {
3406  id serial primary_key
3407  id uuid
3408}
3409"#;
3410        let err = parse_qail(input).expect_err("duplicate columns should fail");
3411        assert!(err.contains("duplicate column 'id' in table 'users'"));
3412    }
3413
3414    #[test]
3415    fn test_parse_table_skips_sql_style_comments_inside_block() {
3416        let input = r#"
3417table users {
3418  -- external auth identifier
3419  id UUID primary_key
3420  # display field
3421  name TEXT
3422}
3423"#;
3424        let schema = parse_qail(input).expect("comments inside table blocks should parse");
3425        let table = &schema.tables["users"];
3426        assert_eq!(table.columns.len(), 2);
3427        assert!(table.columns.iter().any(|col| col.name == "id"));
3428        assert!(table.columns.iter().any(|col| col.name == "name"));
3429    }
3430
3431    #[test]
3432    fn test_parse_schema_blocks_skip_full_line_comments() {
3433        let input = r#"
3434enum order_status {
3435  -- active order lifecycle states
3436  pending,
3437  # terminal success state
3438  paid
3439}
3440
3441sequence order_seq {
3442  -- production starts above legacy rows
3443  start 100
3444  # allocate in small chunks
3445  increment 5
3446}
3447
3448bucket avatars {
3449  -- storage backend
3450  provider s3
3451  # primary deployment region
3452  region "ap-southeast-1"
3453}
3454
3455table docs {
3456  id UUID primary_key
3457}
3458
3459policy docs_all on docs for all
3460  -- tenant filter is deliberately delegated to SQL
3461  using $$ true $$
3462"#;
3463
3464        let schema = parse_qail(input).expect("comments inside schema blocks should parse");
3465        assert_eq!(
3466            schema.enums[0].values,
3467            vec!["pending".to_string(), "paid".to_string()]
3468        );
3469        assert_eq!(schema.sequences[0].start, Some(100));
3470        assert_eq!(schema.sequences[0].increment, Some(5));
3471        assert_eq!(schema.resources[0].provider.as_deref(), Some("s3"));
3472        assert_eq!(
3473            schema.resources[0]
3474                .properties
3475                .get("region")
3476                .map(String::as_str),
3477            Some("ap-southeast-1")
3478        );
3479        assert_eq!(schema.policies.len(), 1);
3480        assert!(schema.policies[0].using.is_some());
3481    }
3482
3483    #[test]
3484    fn test_parse_qail_rejects_duplicate_tables() {
3485        let input = r#"
3486table users {
3487  id serial primary_key
3488}
3489
3490table users {
3491  email text
3492}
3493"#;
3494        let err = parse_qail(input).expect_err("duplicate tables should fail");
3495        assert!(err.contains("duplicate table declaration 'users'"));
3496    }
3497
3498    #[test]
3499    fn test_parse_index() {
3500        let input = "unique index idx_users_email on users (email)";
3501        let schema = parse_qail(input).unwrap();
3502        assert_eq!(schema.indexes.len(), 1);
3503        assert!(schema.indexes[0].unique);
3504        assert_eq!(schema.indexes[0].name, "idx_users_email");
3505    }
3506
3507    #[test]
3508    fn test_parse_index_rejects_missing_shape_parts() {
3509        for (input, expected) in [
3510            ("index  on users (email)", "index name is required"),
3511            (
3512                "index idx_users_email on  (email)",
3513                "index table is required",
3514            ),
3515            (
3516                "index idx_users_email on users ()",
3517                "index columns are required",
3518            ),
3519            (
3520                "index idx_users_email on users (email,)",
3521                "empty index column or expression",
3522            ),
3523            (
3524                "index idx_users_email on users (,email)",
3525                "empty index column or expression",
3526            ),
3527            (
3528                "index idx_users_email on users (email,,name)",
3529                "empty index column or expression",
3530            ),
3531        ] {
3532            let err = parse_qail(input).expect_err("invalid index should fail");
3533            assert!(err.contains(expected), "{err}");
3534        }
3535    }
3536
3537    #[test]
3538    fn test_parse_index_rejects_invalid_identifiers() {
3539        for (input, expected) in [
3540            (
3541                "index bad-name on users (email)",
3542                "invalid index name 'bad-name'",
3543            ),
3544            (
3545                "index idx_users_email on bad-table (email)",
3546                "invalid index table 'bad-table'",
3547            ),
3548        ] {
3549            let err = parse_qail(input).expect_err("invalid index identifier should fail");
3550            assert!(err.contains(expected), "{err}");
3551        }
3552
3553        parse_qail("index reporting.idx_users_email on app.users (email)")
3554            .expect("schema-qualified index refs should parse");
3555    }
3556
3557    #[test]
3558    fn test_parse_index_rejects_trailing_content() {
3559        let input = "index idx_users_email on users (email) garbage";
3560        let err = parse_qail(input).expect_err("trailing index content should fail");
3561        assert!(err.contains("trailing content after index definition"));
3562    }
3563
3564    #[test]
3565    fn test_parse_vector_index_methods() {
3566        let input = r#"
3567index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)
3568index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)
3569"#;
3570        let schema = parse_qail(input).unwrap();
3571
3572        assert_eq!(schema.indexes.len(), 2);
3573        assert_eq!(schema.indexes[0].method, IndexMethod::Hnsw);
3574        assert_eq!(
3575            schema.indexes[0].columns,
3576            vec!["embedding vector_l2_ops".to_string()]
3577        );
3578        assert_eq!(schema.indexes[1].method, IndexMethod::IvfFlat);
3579        assert_eq!(
3580            schema.indexes[1].columns,
3581            vec!["embedding vector_cosine_ops".to_string()]
3582        );
3583    }
3584
3585    #[test]
3586    fn test_parse_covering_concurrent_partial_index() {
3587        let input = "unique index concurrently idx_users_email_cover on users using btree (email) include (name, created_at) where deleted_at IS NULL";
3588        let schema = parse_qail(input).unwrap();
3589
3590        assert_eq!(schema.indexes.len(), 1);
3591        let idx = &schema.indexes[0];
3592        assert!(idx.unique);
3593        assert!(idx.concurrently);
3594        assert_eq!(idx.method, IndexMethod::BTree);
3595        assert_eq!(idx.columns, vec!["email".to_string()]);
3596        assert_eq!(
3597            idx.include,
3598            vec!["name".to_string(), "created_at".to_string()]
3599        );
3600        assert!(matches!(
3601            idx.where_clause.as_ref(),
3602            Some(CheckExpr::Sql(sql)) if sql == "deleted_at IS NULL"
3603        ));
3604    }
3605
3606    #[test]
3607    fn test_parse_index_rejects_unknown_method() {
3608        let input = "index idx_users_email on users using btre (email)";
3609        let err = parse_qail(input).expect_err("unknown index method should fail");
3610        assert!(err.contains("unknown index method: btre"));
3611    }
3612
3613    #[test]
3614    fn test_parse_index_rejects_duplicate_names() {
3615        let input = r#"
3616index idx_users_email on users (email)
3617unique index idx_users_email on users (tenant_id, email)
3618"#;
3619        let err = parse_qail(input).expect_err("duplicate indexes should fail");
3620        assert!(err.contains("duplicate index declaration 'idx_users_email'"));
3621    }
3622
3623    #[test]
3624    fn test_parse_rename() {
3625        let input = "rename users.username -> users.name";
3626        let schema = parse_qail(input).unwrap();
3627        assert_eq!(schema.migrations.len(), 1);
3628        assert!(matches!(
3629            &schema.migrations[0],
3630            MigrationHint::Rename { from, to } if from == "users.username" && to == "users.name"
3631        ));
3632    }
3633
3634    #[test]
3635    fn test_parse_rename_rejects_empty_paths() {
3636        for input in ["rename users.username -> ", "rename  -> users.name"] {
3637            let err = parse_qail(input).expect_err("empty rename paths should fail");
3638            assert!(err.contains("rename requires non-empty source and target"));
3639        }
3640    }
3641
3642    #[test]
3643    fn test_parse_transform_rejects_empty_parts() {
3644        for input in ["transform age * 12 -> ", "transform  -> age_months"] {
3645            let err = parse_qail(input).expect_err("empty transform parts should fail");
3646            assert!(err.contains("transform requires non-empty expression and target"));
3647        }
3648    }
3649
3650    #[test]
3651    fn test_parse_drop_rejects_empty_target() {
3652        let err = parse_qail("drop  confirm").expect_err("empty drop target should fail");
3653        assert!(err.contains("drop requires a target"));
3654    }
3655
3656    #[test]
3657    fn test_parse_full_schema() {
3658        let input = r#"
3659# User table
3660table users {
3661  id serial primary_key
3662  name text not_null
3663  email text unique
3664  created_at timestamptz default now()
3665}
3666
3667unique index idx_users_email on users (email)
3668
3669rename users.username -> users.name
3670"#;
3671        let schema = parse_qail(input).unwrap();
3672        assert_eq!(schema.tables.len(), 1);
3673        assert_eq!(schema.indexes.len(), 1);
3674        assert_eq!(schema.migrations.len(), 1);
3675    }
3676
3677    #[test]
3678    fn test_parse_extension() {
3679        let input = r#"extension "uuid-ossp""#;
3680        let schema = parse_qail(input).unwrap();
3681        assert_eq!(schema.extensions.len(), 1);
3682        assert_eq!(schema.extensions[0].name, "uuid-ossp");
3683    }
3684
3685    #[test]
3686    fn test_parse_extension_with_options() {
3687        let input = r#"extension "uuid-ossp" schema public version "1.1""#;
3688        let schema = parse_qail(input).unwrap();
3689        assert_eq!(schema.extensions[0].name, "uuid-ossp");
3690        assert_eq!(schema.extensions[0].schema.as_deref(), Some("public"));
3691        assert_eq!(schema.extensions[0].version.as_deref(), Some("1.1"));
3692    }
3693
3694    #[test]
3695    fn test_parse_extension_round_trips_quoted_tokens() {
3696        let input = r#"extension "uuid""ossp" schema "tenant schema" version "1.""1""#;
3697        let schema = parse_qail(input).unwrap();
3698        assert_eq!(schema.extensions[0].name, r#"uuid"ossp"#);
3699        assert_eq!(
3700            schema.extensions[0].schema.as_deref(),
3701            Some("tenant schema")
3702        );
3703        assert_eq!(schema.extensions[0].version.as_deref(), Some(r#"1."1"#));
3704
3705        let rendered = super::super::schema::to_qail_string(&schema);
3706        assert!(
3707            rendered.contains(r#"extension "uuid""ossp" schema "tenant schema" version "1.""1""#)
3708        );
3709
3710        let reparsed = parse_qail(&rendered).unwrap();
3711        assert_eq!(reparsed.extensions, schema.extensions);
3712    }
3713
3714    #[test]
3715    fn test_parse_extension_unquoted() {
3716        let input = "extension pgcrypto";
3717        let schema = parse_qail(input).unwrap();
3718        assert_eq!(schema.extensions[0].name, "pgcrypto");
3719    }
3720
3721    #[test]
3722    fn test_parse_extension_rejects_unterminated_quote() {
3723        let input = r#"extension "uuid-ossp"#;
3724        let err = parse_qail(input).expect_err("unterminated extension quote should fail");
3725        assert!(err.contains("unterminated quoted extension token"));
3726    }
3727
3728    #[test]
3729    fn test_parse_extension_rejects_duplicate_options() {
3730        for (input, expected) in [
3731            (
3732                "extension pgcrypto schema public schema auth",
3733                "duplicate extension option: schema",
3734            ),
3735            (
3736                r#"extension "uuid-ossp" version "1.0" version "1.1""#,
3737                "duplicate extension option: version",
3738            ),
3739        ] {
3740            let err = parse_qail(input).expect_err("duplicate extension option should fail");
3741            assert!(err.contains(expected), "{err}");
3742        }
3743    }
3744
3745    #[test]
3746    fn test_parse_comment_on_table() {
3747        let input = r#"comment on users "User accounts table""#;
3748        let schema = parse_qail(input).unwrap();
3749        assert_eq!(schema.comments.len(), 1);
3750        assert_eq!(schema.comments[0].text, "User accounts table");
3751    }
3752
3753    #[test]
3754    fn test_parse_comment_on_column() {
3755        let input = r#"comment on users.email "Primary contact email""#;
3756        let schema = parse_qail(input).unwrap();
3757        assert_eq!(schema.comments.len(), 1);
3758        assert_eq!(schema.comments[0].text, "Primary contact email");
3759    }
3760
3761    #[test]
3762    fn test_parse_comment_rejects_missing_target() {
3763        let input = r#"comment on "orphaned comment""#;
3764        let err = parse_qail(input).expect_err("missing comment target should fail");
3765        assert!(err.contains("comment target is required"));
3766    }
3767
3768    #[test]
3769    fn test_parse_comment_rejects_empty_column_target_segments() {
3770        for input in [
3771            r#"comment on users. "missing column""#,
3772            r#"comment on .email "missing table""#,
3773        ] {
3774            let err = parse_qail(input).expect_err("empty comment target segment should fail");
3775            assert!(err.contains("invalid comment target"));
3776        }
3777    }
3778
3779    #[test]
3780    fn test_parse_comment_rejects_invalid_targets() {
3781        for input in [
3782            r#"comment on bad-table "bad table""#,
3783            r#"comment on users.bad-column "bad column""#,
3784        ] {
3785            let err = parse_qail(input).expect_err("invalid comment target should fail");
3786            assert!(err.contains("invalid comment target"), "{err}");
3787        }
3788
3789        parse_qail(r#"comment on app.users.email "email""#)
3790            .expect("schema-qualified comment target should parse");
3791    }
3792
3793    #[test]
3794    fn test_parse_comment_round_trips_doubled_quotes() {
3795        let input = r#"comment on users "He said ""hello""""#;
3796        let schema = parse_qail(input).unwrap();
3797        assert_eq!(schema.comments[0].text, r#"He said "hello""#);
3798
3799        let rendered = super::super::schema::to_qail_string(&schema);
3800        assert!(rendered.contains(r#"comment on users "He said ""hello""""#));
3801
3802        let reparsed = parse_qail(&rendered).unwrap();
3803        assert_eq!(reparsed.comments[0].text, schema.comments[0].text);
3804    }
3805
3806    #[test]
3807    fn test_parse_comment_accepts_pulled_inner_quotes() {
3808        let input =
3809            r##"comment on pickup_zones.ribbon_color "Hex color (e.g., "#f97316" for orange)""##;
3810        let schema = parse_qail(input).expect("pulled comments with inner quotes should parse");
3811        assert_eq!(
3812            schema.comments[0].text,
3813            r##"Hex color (e.g., "#f97316" for orange)"##
3814        );
3815    }
3816
3817    #[test]
3818    fn test_parse_sequence_simple() {
3819        let input = "sequence order_number_seq";
3820        let schema = parse_qail(input).unwrap();
3821        assert_eq!(schema.sequences.len(), 1);
3822        assert_eq!(schema.sequences[0].name, "order_number_seq");
3823    }
3824
3825    #[test]
3826    fn test_parse_sequence_with_options() {
3827        let input = "sequence order_seq { start 1000 increment 1 cache 10 cycle }";
3828        let schema = parse_qail(input).unwrap();
3829        let seq = &schema.sequences[0];
3830        assert_eq!(seq.name, "order_seq");
3831        assert_eq!(seq.start, Some(1000));
3832        assert_eq!(seq.increment, Some(1));
3833        assert_eq!(seq.cache, Some(10));
3834        assert!(seq.cycle);
3835    }
3836
3837    #[test]
3838    fn test_parse_rejects_unclosed_sequence_block() {
3839        let input = r#"
3840sequence order_seq {
3841  start 1000
3842  increment 1
3843"#;
3844
3845        let err = parse_qail(input).expect_err("unclosed sequence block should be rejected");
3846        assert!(err.contains("Unclosed sequence block"));
3847    }
3848
3849    #[test]
3850    fn test_parse_sequence_rejects_missing_name() {
3851        let input = "sequence { start 1 }";
3852        let err = parse_qail(input).expect_err("missing sequence name should fail");
3853        assert!(err.contains("sequence name is missing before '{'"));
3854    }
3855
3856    #[test]
3857    fn test_parse_sequence_rejects_invalid_name() {
3858        for (input, expected) in [
3859            ("sequence bad-name", "invalid sequence name 'bad-name'"),
3860            (
3861                "sequence bad name { start 1 }",
3862                "invalid sequence name 'bad name'",
3863            ),
3864        ] {
3865            let err = parse_qail(input).expect_err("invalid sequence name should fail");
3866            assert!(err.contains(expected), "{err}");
3867        }
3868
3869        parse_qail("sequence billing.order_ids { start 1 }")
3870            .expect("schema-qualified sequence names should parse");
3871    }
3872
3873    #[test]
3874    fn test_parse_sequence_rejects_duplicate_names() {
3875        let input = r#"
3876sequence order_ids
3877sequence order_ids { start 100 }
3878"#;
3879        let err = parse_qail(input).expect_err("duplicate sequences should fail");
3880        assert!(err.contains("duplicate sequence declaration 'order_ids'"));
3881    }
3882
3883    #[test]
3884    fn test_parse_sequence_rejects_trailing_content_after_block() {
3885        let input = "sequence order_seq { start 1 } }";
3886        let err = parse_qail(input).expect_err("extra sequence content should fail");
3887        assert!(err.contains("trailing content after sequence block"));
3888    }
3889
3890    #[test]
3891    fn test_parse_sequence_rejects_duplicate_options() {
3892        for (input, expected) in [
3893            (
3894                "sequence order_seq { start 1 start 2 }",
3895                "duplicate sequence option: start",
3896            ),
3897            (
3898                "sequence order_seq { cycle cycle }",
3899                "duplicate sequence option: cycle",
3900            ),
3901            (
3902                "sequence order_seq { owned_by users.id owned_by orders.id }",
3903                "duplicate sequence option: owned_by",
3904            ),
3905        ] {
3906            let err = parse_qail(input).expect_err("duplicate sequence option should fail");
3907            assert!(err.contains(expected), "{err}");
3908        }
3909    }
3910
3911    #[test]
3912    fn test_parse_sequence_rejects_invalid_option_values() {
3913        for (input, expected) in [
3914            (
3915                "sequence order_seq { owned_by bad-table.id }",
3916                "invalid sequence owned_by reference 'bad-table.id'",
3917            ),
3918            (
3919                "sequence order_seq { owned_by users }",
3920                "invalid sequence owned_by reference 'users'",
3921            ),
3922            (
3923                "sequence order_seq { as big-int }",
3924                "invalid sequence data type 'big-int'",
3925            ),
3926        ] {
3927            let err = parse_qail(input).expect_err("invalid sequence option value should fail");
3928            assert!(err.contains(expected), "{err}");
3929        }
3930
3931        parse_qail("sequence billing.order_ids { owned_by app.orders.id as bigint }")
3932            .expect("schema-qualified owned_by refs should parse");
3933    }
3934
3935    #[test]
3936    fn test_parse_full_schema_with_extensions() {
3937        let input = r#"
3938extension "uuid-ossp"
3939extension pgcrypto
3940
3941table users {
3942  id uuid primary_key
3943  name text not_null
3944}
3945
3946sequence order_seq { start 1000 increment 1 }
3947
3948comment on users "User accounts"
3949comment on users.name "Full name"
3950"#;
3951        let schema = parse_qail(input).unwrap();
3952        assert_eq!(schema.extensions.len(), 2);
3953        assert_eq!(schema.tables.len(), 1);
3954        assert_eq!(schema.sequences.len(), 1);
3955        assert_eq!(schema.comments.len(), 2);
3956    }
3957
3958    // ======================== Phase 2 Tests ========================
3959
3960    #[test]
3961    fn test_parse_enum_inline() {
3962        let input = "enum status { active, inactive, pending }";
3963        let schema = parse_qail(input).unwrap();
3964        assert_eq!(schema.enums.len(), 1);
3965        assert_eq!(schema.enums[0].name, "status");
3966        assert_eq!(
3967            schema.enums[0].values,
3968            vec!["active", "inactive", "pending"]
3969        );
3970    }
3971
3972    #[test]
3973    fn test_parse_enum_quoted_values_with_commas_and_quotes() {
3974        let input =
3975            r#"enum status { "needs review", "card,bank", "quote "" ok", 'single '' ok', "" }"#;
3976        let schema = parse_qail(input).unwrap();
3977
3978        assert_eq!(
3979            schema.enums[0].values,
3980            vec![
3981                "needs review".to_string(),
3982                "card,bank".to_string(),
3983                "quote \" ok".to_string(),
3984                "single ' ok".to_string(),
3985                String::new(),
3986            ]
3987        );
3988    }
3989
3990    #[test]
3991    fn test_parse_enum_rejects_trailing_content_after_block() {
3992        let input = "enum status { active } garbage";
3993        let err = parse_qail(input).expect_err("trailing enum content should fail");
3994        assert!(err.contains("trailing content after enum block"));
3995    }
3996
3997    #[test]
3998    fn test_parse_enum_rejects_missing_name() {
3999        let input = "enum { active }";
4000        let err = parse_qail(input).expect_err("missing enum name should fail");
4001        assert!(err.contains("enum name is missing before '{'"));
4002    }
4003
4004    #[test]
4005    fn test_parse_enum_rejects_duplicate_values() {
4006        let input = "enum order_status { pending, paid, pending }";
4007        let err = parse_qail(input).expect_err("duplicate enum values should fail");
4008        assert!(err.contains("duplicate enum value 'pending'"));
4009    }
4010
4011    #[test]
4012    fn test_parse_enum_rejects_duplicate_names() {
4013        let input = r#"
4014enum status { pending, approved }
4015enum status { draft, archived }
4016"#;
4017        let err = parse_qail(input).expect_err("duplicate enum declarations should fail");
4018        assert!(err.contains("duplicate enum declaration 'status'"));
4019    }
4020
4021    #[test]
4022    fn test_parse_enum_rejects_empty_unquoted_values() {
4023        for input in [
4024            "enum order_status { pending,, paid }",
4025            "enum order_status { pending, }",
4026        ] {
4027            let err = parse_qail(input).expect_err("empty enum values should fail");
4028            assert!(err.contains("enum value is empty"), "{err}");
4029        }
4030    }
4031
4032    #[test]
4033    fn test_enum_to_qail_string_round_trips_quoted_values() {
4034        let input = r#"enum status { "needs review", "card,bank", "quote "" ok", plain }"#;
4035        let schema = parse_qail(input).unwrap();
4036        let output = super::super::schema::to_qail_string(&schema);
4037        let reparsed = parse_qail(&output).unwrap();
4038
4039        assert_eq!(reparsed.enums[0].values, schema.enums[0].values);
4040        assert!(
4041            output.contains(r#"enum status { "needs review", "card,bank", "quote "" ok", plain }"#)
4042        );
4043    }
4044
4045    #[test]
4046    fn test_parse_enum_multiline() {
4047        let input = r#"
4048enum booking_status {
4049  draft,
4050  confirmed,
4051  cancelled,
4052  completed
4053}
4054"#;
4055        let schema = parse_qail(input).unwrap();
4056        assert_eq!(schema.enums[0].name, "booking_status");
4057        assert_eq!(schema.enums[0].values.len(), 4);
4058        assert_eq!(schema.enums[0].values[0], "draft");
4059        assert_eq!(schema.enums[0].values[3], "completed");
4060    }
4061
4062    #[test]
4063    fn test_parse_expression_index() {
4064        let input = "index idx_users_email_lower on users ((lower(email)))";
4065        let schema = parse_qail(input).unwrap();
4066        assert_eq!(schema.indexes.len(), 1);
4067        let idx = &schema.indexes[0];
4068        assert_eq!(idx.name, "idx_users_email_lower");
4069        assert!(!idx.expressions.is_empty());
4070        assert_eq!(idx.expressions[0], "(lower(email))");
4071    }
4072
4073    #[test]
4074    fn test_parse_expression_index_ignores_commas_and_parens_inside_literals() {
4075        let input = r#"
4076index idx_docs_meta on docs (metadata->>'a,b', lower(title))
4077index idx_docs_regex on docs (regexp_replace(title, ')', '', 'g'))
4078"#;
4079        let schema = parse_qail(input).unwrap();
4080
4081        assert_eq!(schema.indexes[0].expressions[0], "metadata->>'a,b'");
4082        assert_eq!(schema.indexes[0].expressions[1], "lower(title)");
4083        assert_eq!(
4084            schema.indexes[1].expressions[0],
4085            "regexp_replace(title, ')', '', 'g')"
4086        );
4087    }
4088
4089    #[test]
4090    fn test_parse_multi_column_fk() {
4091        let input = r#"
4092table bookings {
4093  id serial primary_key
4094  route_id integer not_null
4095  schedule_id integer not_null
4096  foreign_key (route_id, schedule_id) references schedules(route_id, schedule_id)
4097}
4098"#;
4099        let schema = parse_qail(input).unwrap();
4100        let table = &schema.tables["bookings"];
4101        assert_eq!(table.multi_column_fks.len(), 1);
4102        let fk = &table.multi_column_fks[0];
4103        assert_eq!(fk.columns, vec!["route_id", "schedule_id"]);
4104        assert_eq!(fk.ref_table, "schedules");
4105        assert_eq!(fk.ref_columns, vec!["route_id", "schedule_id"]);
4106    }
4107
4108    #[test]
4109    fn test_parse_multi_column_fk_options_roundtrip() {
4110        let input = r#"
4111table bookings {
4112  id serial primary_key
4113  route_id integer not_null
4114  schedule_id integer not_null
4115  foreign_key (route_id, schedule_id) references schedules(route_id, schedule_id) constraint fk_bookings_schedule on_delete cascade on_update restrict initially_deferred
4116}
4117"#;
4118        let schema = parse_qail(input).unwrap();
4119        let table = &schema.tables["bookings"];
4120        assert_eq!(table.multi_column_fks.len(), 1);
4121        let fk = &table.multi_column_fks[0];
4122        assert_eq!(fk.name.as_deref(), Some("fk_bookings_schedule"));
4123        assert!(matches!(fk.on_delete, FkAction::Cascade));
4124        assert!(matches!(fk.on_update, FkAction::Restrict));
4125        assert!(matches!(fk.deferrable, Deferrable::InitiallyDeferred));
4126
4127        let rendered = super::super::schema::to_qail_string(&schema);
4128        assert!(rendered.contains("constraint fk_bookings_schedule"));
4129        assert!(rendered.contains("on_delete cascade"));
4130        assert!(rendered.contains("on_update restrict"));
4131        assert!(rendered.contains("initially_deferred"));
4132
4133        let reparsed = parse_qail(&rendered).unwrap();
4134        let reparsed_fk = &reparsed.tables["bookings"].multi_column_fks[0];
4135        assert_eq!(reparsed_fk.name, fk.name);
4136        assert_eq!(reparsed_fk.on_delete, fk.on_delete);
4137        assert_eq!(reparsed_fk.on_update, fk.on_update);
4138        assert_eq!(reparsed_fk.deferrable, fk.deferrable);
4139    }
4140
4141    #[test]
4142    fn test_parse_single_column_fk_deferrable_roundtrip() {
4143        let input = r#"
4144table bookings {
4145  id serial primary_key
4146  user_id uuid references users(id) on_delete cascade initially_immediate
4147}
4148"#;
4149        let schema = parse_qail(input).unwrap();
4150        let fk = schema.tables["bookings"].columns[1]
4151            .foreign_key
4152            .as_ref()
4153            .expect("foreign key should parse");
4154        assert!(matches!(fk.on_delete, FkAction::Cascade));
4155        assert!(matches!(fk.deferrable, Deferrable::InitiallyImmediate));
4156
4157        let rendered = super::super::schema::to_qail_string(&schema);
4158        assert!(rendered.contains("on_delete cascade initially_immediate"));
4159
4160        let reparsed = parse_qail(&rendered).unwrap();
4161        let reparsed_fk = reparsed.tables["bookings"].columns[1]
4162            .foreign_key
4163            .as_ref()
4164            .expect("foreign key should reparse");
4165        assert_eq!(reparsed_fk.on_delete, fk.on_delete);
4166        assert_eq!(reparsed_fk.deferrable, fk.deferrable);
4167    }
4168
4169    #[test]
4170    fn test_parse_column_name_starting_with_foreign_key() {
4171        let input = r#"
4172table audits {
4173  foreign_key_id uuid
4174}
4175"#;
4176        let schema = parse_qail(input).unwrap();
4177        let table = &schema.tables["audits"];
4178        assert_eq!(table.columns.len(), 1);
4179        assert_eq!(table.columns[0].name, "foreign_key_id");
4180        assert!(table.multi_column_fks.is_empty());
4181    }
4182
4183    #[test]
4184    fn test_parse_multi_column_fk_rejects_invalid_shapes() {
4185        for (input, expected) in [
4186            (
4187                "table bookings {\n  foreign_key () references schedules(id)\n}",
4188                "foreign_key local columns are required",
4189            ),
4190            (
4191                "table bookings {\n  foreign_key (route_id) references (id)\n}",
4192                "foreign_key referenced table is required",
4193            ),
4194            (
4195                "table bookings {\n  foreign_key (route_id,) references schedules(id)\n}",
4196                "foreign_key local columns are required",
4197            ),
4198            (
4199                "table bookings {\n  foreign_key (route-id) references schedules(id)\n}",
4200                "invalid foreign_key local column 'route-id'",
4201            ),
4202            (
4203                "table bookings {\n  foreign_key (route_id) references bad-table(id)\n}",
4204                "invalid foreign_key referenced table 'bad-table'",
4205            ),
4206            (
4207                "table bookings {\n  foreign_key (route_id) references schedules(bad-id)\n}",
4208                "invalid foreign_key referenced column 'bad-id'",
4209            ),
4210            (
4211                "table bookings {\n  foreign_key (route_id, route_id) references schedules(id, schedule_id)\n}",
4212                "duplicate foreign_key local column 'route_id'",
4213            ),
4214            (
4215                "table bookings {\n  foreign_key (route_id, schedule_id) references schedules(id, id)\n}",
4216                "duplicate foreign_key referenced column 'id'",
4217            ),
4218            (
4219                "table bookings {\n  foreign_key (route_id, schedule_id) references schedules(id)\n}",
4220                "foreign_key local/ref column counts must match",
4221            ),
4222            (
4223                "table bookings {\n  foreign_key (route_id) references schedules(id) bananas cascade\n}",
4224                "unknown foreign_key option 'bananas' after references",
4225            ),
4226        ] {
4227            let err = parse_qail(input).expect_err("invalid multi-column fk should fail");
4228            assert!(err.contains(expected), "{err}");
4229        }
4230    }
4231
4232    #[test]
4233    fn test_parse_full_schema_phase2() {
4234        let input = r#"
4235enum status { active, inactive }
4236
4237table users {
4238  id serial primary_key
4239  name text not_null
4240  status text not_null
4241}
4242
4243index idx_name_lower on users ((lower(name)))
4244"#;
4245        let schema = parse_qail(input).unwrap();
4246        assert_eq!(schema.enums.len(), 1);
4247        assert_eq!(schema.tables.len(), 1);
4248        assert_eq!(schema.indexes.len(), 1);
4249        assert!(!schema.indexes[0].expressions.is_empty());
4250    }
4251
4252    // ======================== Phase 3 Tests ========================
4253
4254    #[test]
4255    fn test_parse_view() {
4256        let input = "view active_users $$ SELECT * FROM users WHERE active = true $$";
4257        let schema = parse_qail(input).unwrap();
4258        assert_eq!(schema.views.len(), 1);
4259        assert_eq!(schema.views[0].name, "active_users");
4260        assert!(schema.views[0].query.contains("SELECT * FROM users"));
4261        assert!(!schema.views[0].materialized);
4262        // Owner-rights is still the parse default — it is Postgres's default,
4263        // and silently flipping it would change the meaning of every existing
4264        // schema on the next apply.
4265        assert!(!schema.views[0].security_invoker);
4266    }
4267
4268    #[test]
4269    fn test_parse_view_security_invoker() {
4270        let input = "view tenant_rows security_invoker $$ SELECT * FROM orders $$";
4271        let schema = parse_qail(input).unwrap();
4272        assert_eq!(schema.views.len(), 1);
4273        assert_eq!(schema.views[0].name, "tenant_rows");
4274        assert!(schema.views[0].security_invoker);
4275        assert!(schema.views[0].query.contains("SELECT * FROM orders"));
4276    }
4277
4278    #[test]
4279    fn test_view_security_invoker_round_trips_through_qail_text() {
4280        // pull → schema.qail → apply must preserve the flag; dropping it on a
4281        // round-trip would silently re-open the RLS bypass it exists to close.
4282        let input = "view tenant_rows security_invoker $$ SELECT * FROM orders $$";
4283        let schema = parse_qail(input).unwrap();
4284        let rendered = crate::migrate::schema::to_qail_string(&schema);
4285        assert!(
4286            rendered.contains("view tenant_rows security_invoker"),
4287            "security_invoker lost when rendering back to qail: {rendered}"
4288        );
4289        let reparsed = parse_qail(&rendered).unwrap();
4290        assert!(reparsed.views[0].security_invoker);
4291    }
4292
4293    #[test]
4294    fn test_parse_view_name_ending_in_security_invoker_is_not_a_modifier() {
4295        // A view legitimately NAMED `..._security_invoker` must not have its
4296        // name eaten by the modifier check.
4297        let input = "view audit_security_invoker $$ SELECT 1 $$";
4298        let schema = parse_qail(input).unwrap();
4299        assert_eq!(schema.views[0].name, "audit_security_invoker");
4300        assert!(!schema.views[0].security_invoker);
4301    }
4302
4303    #[test]
4304    fn test_parse_materialized_view() {
4305        let input = r#"
4306materialized view booking_stats $$
4307  SELECT route_id, count(*) as total
4308  FROM bookings
4309  GROUP BY route_id
4310$$
4311"#;
4312        let schema = parse_qail(input).unwrap();
4313        assert_eq!(schema.views[0].name, "booking_stats");
4314        assert!(schema.views[0].materialized);
4315        assert!(schema.views[0].query.contains("GROUP BY"));
4316    }
4317
4318    #[test]
4319    fn test_parse_view_with_tagged_dollar_delimiter() {
4320        let input = r#"
4321view debug_sql $qail$
4322  SELECT '$$literal$$' AS sample
4323$qail$
4324"#;
4325        let schema = parse_qail(input).unwrap();
4326        let rendered = super::super::schema::to_qail_string(&schema);
4327        let reparsed = parse_qail(&rendered).unwrap();
4328
4329        assert_eq!(schema.views[0].name, "debug_sql");
4330        assert!(schema.views[0].query.contains("$$literal$$"));
4331        assert_eq!(reparsed.views[0].query, schema.views[0].query);
4332    }
4333
4334    #[test]
4335    fn test_parse_view_rejects_missing_name() {
4336        let input = "view $$ SELECT 1 $$";
4337        let err = parse_qail(input).expect_err("missing view name should fail");
4338        assert!(err.contains("view name is required"));
4339    }
4340
4341    #[test]
4342    fn test_parse_view_rejects_invalid_name() {
4343        let input = "materialized view bad-name $$ SELECT 1 $$";
4344        let err = parse_qail(input).expect_err("invalid view name should fail");
4345        assert!(err.contains("invalid view name 'bad-name'"));
4346
4347        parse_qail("view reporting.active_users $$ SELECT 1 $$")
4348            .expect("schema-qualified view names should parse");
4349    }
4350
4351    #[test]
4352    fn test_parse_view_rejects_duplicate_names() {
4353        let input = r#"
4354view active_users $$ SELECT 1 $$
4355materialized view active_users $$ SELECT 2 $$
4356"#;
4357        let err = parse_qail(input).expect_err("duplicate views should fail");
4358        assert!(err.contains("duplicate view declaration 'active_users'"));
4359    }
4360
4361    #[test]
4362    fn test_parse_function() {
4363        let input = "function set_updated_at() returns trigger language plpgsql $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$";
4364        let schema = parse_qail(input).unwrap();
4365        assert_eq!(schema.functions.len(), 1);
4366        assert_eq!(schema.functions[0].name, "set_updated_at");
4367        assert_eq!(schema.functions[0].returns, "trigger");
4368        assert_eq!(schema.functions[0].language, "plpgsql");
4369        assert!(schema.functions[0].body.contains("RETURN NEW"));
4370    }
4371
4372    #[test]
4373    fn test_parse_function_with_volatility() {
4374        let input = "function is_super_admin() returns boolean language plpgsql stable $$ BEGIN RETURN true; END; $$";
4375        let schema = parse_qail(input).unwrap();
4376        assert_eq!(schema.functions.len(), 1);
4377        assert_eq!(schema.functions[0].name, "is_super_admin");
4378        assert_eq!(schema.functions[0].volatility.as_deref(), Some("stable"));
4379    }
4380
4381    #[test]
4382    fn test_parse_function_rejects_missing_name() {
4383        let input = "function () returns int language sql $$ SELECT 1 $$";
4384        let err = parse_qail(input).expect_err("missing function name should fail");
4385        assert!(err.contains("function name is required"));
4386    }
4387
4388    #[test]
4389    fn test_parse_function_rejects_invalid_name() {
4390        let input = "function bad-name() returns int language sql $$ SELECT 1 $$";
4391        let err = parse_qail(input).expect_err("invalid function name should fail");
4392        assert!(err.contains("invalid function name 'bad-name'"));
4393
4394        parse_qail("function util.normalize_email(email text) returns text language sql $$ SELECT lower(email) $$")
4395            .expect("schema-qualified function names should parse");
4396    }
4397
4398    #[test]
4399    fn test_parse_function_rejects_missing_header_fields() {
4400        for (input, expected) in [
4401            (
4402                "function f() language sql $$ SELECT 1 $$",
4403                "function missing returns clause",
4404            ),
4405            (
4406                "function f() returns language sql $$ SELECT 1 $$",
4407                "function returns clause requires a type",
4408            ),
4409            (
4410                "function f() returns int $$ SELECT 1 $$",
4411                "function missing language clause",
4412            ),
4413        ] {
4414            let err = parse_qail(input).expect_err("missing function header field should fail");
4415            assert!(err.contains(expected), "{err}");
4416        }
4417    }
4418
4419    #[test]
4420    fn test_parse_function_rejects_duplicate_header_fields() {
4421        for (input, expected) in [
4422            (
4423                "function f() returns int returns text language sql $$ SELECT 1 $$",
4424                "function has duplicate returns clauses",
4425            ),
4426            (
4427                "function f() returns int language sql language plpgsql $$ SELECT 1 $$",
4428                "function has duplicate language clauses",
4429            ),
4430            (
4431                "function f() returns int language sql stable immutable $$ SELECT 1 $$",
4432                "function has duplicate volatility clauses",
4433            ),
4434        ] {
4435            let err = parse_qail(input).expect_err("duplicate function header field should fail");
4436            assert!(err.contains(expected), "{err}");
4437        }
4438    }
4439
4440    #[test]
4441    fn test_parse_function_rejects_unknown_header_tokens() {
4442        let input = "function f() returns int language sql security definer $$ SELECT 1 $$";
4443        let err = parse_qail(input).expect_err("unknown function header token should fail");
4444        assert!(
4445            err.contains("unknown function header token 'security'"),
4446            "{err}"
4447        );
4448    }
4449
4450    #[test]
4451    fn test_parse_function_rejects_invalid_language() {
4452        let input = "function f() returns int language bad-lang $$ SELECT 1 $$";
4453        let err = parse_qail(input).expect_err("invalid function language should fail");
4454        assert!(err.contains("invalid function language 'bad-lang'"));
4455    }
4456
4457    #[test]
4458    fn test_parse_function_args_with_nested_type_parentheses() {
4459        let input = r#"
4460function normalize_amount(amount numeric(10,2), labels text[]) returns numeric language sql $$
4461  SELECT amount
4462$$
4463"#;
4464        let schema = parse_qail(input).unwrap();
4465        let func = &schema.functions[0];
4466
4467        assert_eq!(
4468            func.args,
4469            vec![
4470                "amount numeric(10,2)".to_string(),
4471                "labels text[]".to_string()
4472            ]
4473        );
4474        assert_eq!(func.returns, "numeric");
4475        assert_eq!(func.language, "sql");
4476    }
4477
4478    #[test]
4479    fn test_parse_function_rejects_empty_args() {
4480        for input in [
4481            "function f(a int,) returns int language sql $$ SELECT a $$",
4482            "function f(,a int) returns int language sql $$ SELECT a $$",
4483            "function f(a int,,b int) returns int language sql $$ SELECT a $$",
4484        ] {
4485            let err = parse_qail(input).expect_err("empty function arg should fail");
4486            assert!(err.contains("empty function argument"), "{err}");
4487        }
4488    }
4489
4490    #[test]
4491    fn test_parse_function_rejects_duplicate_arg_names() {
4492        for input in [
4493            "function f(email text, email text) returns text language sql $$ SELECT email $$",
4494            "function f(IN email text, email text) returns text language sql $$ SELECT email $$",
4495        ] {
4496            let err = parse_qail(input).expect_err("duplicate function arg should fail");
4497            assert!(err.contains("duplicate function argument 'email'"), "{err}");
4498        }
4499    }
4500
4501    #[test]
4502    fn test_parse_function_rejects_invalid_arg_names() {
4503        for input in [
4504            "function f(bad-name text) returns text language sql $$ SELECT bad_name $$",
4505            "function f(IN bad-name text) returns text language sql $$ SELECT bad_name $$",
4506        ] {
4507            let err = parse_qail(input).expect_err("invalid function arg should fail");
4508            assert!(
4509                err.contains("invalid function argument name 'bad-name'"),
4510                "{err}"
4511            );
4512        }
4513
4514        let err = parse_qail("function f(IN) returns text language sql $$ SELECT 1 $$")
4515            .expect_err("mode-only function arg should fail");
4516        assert!(
4517            err.contains("function argument mode 'IN' requires a name"),
4518            "{err}"
4519        );
4520    }
4521
4522    #[test]
4523    fn test_parse_function_returns_table_with_nested_type_parentheses() {
4524        let input = r#"
4525function report_amounts() returns table(id uuid, amount numeric(10,2), language text) language sql stable $$
4526  SELECT id, amount, language FROM reports
4527$$
4528"#;
4529        let schema = parse_qail(input).unwrap();
4530        let func = &schema.functions[0];
4531
4532        assert_eq!(
4533            func.returns,
4534            "table(id uuid, amount numeric(10,2), language text)"
4535        );
4536        assert_eq!(func.language, "sql");
4537        assert_eq!(func.volatility.as_deref(), Some("stable"));
4538    }
4539
4540    #[test]
4541    fn test_function_to_qail_string_round_trips_body_with_dollar_delimiter() {
4542        let input = r#"
4543function debug_notice() returns void language plpgsql $qail$
4544BEGIN
4545  RAISE NOTICE $$hello$$;
4546END;
4547$qail$
4548"#;
4549        let schema = parse_qail(input).unwrap();
4550        let rendered = super::super::schema::to_qail_string(&schema);
4551        let reparsed = parse_qail(&rendered).unwrap();
4552
4553        assert!(rendered.contains("$qail$"));
4554        assert_eq!(reparsed.functions[0].body, schema.functions[0].body);
4555    }
4556
4557    #[test]
4558    fn test_parse_function_rejects_duplicate_signatures() {
4559        let input = r#"
4560function normalize_email(email text) returns text language sql $$ SELECT lower(email) $$
4561function normalize_email(email text) returns text language sql $$ SELECT trim(email) $$
4562"#;
4563        let err = parse_qail(input).expect_err("duplicate function signatures should fail");
4564        assert!(err.contains("duplicate function declaration 'normalize_email(email text)'"));
4565    }
4566
4567    #[test]
4568    fn test_parse_function_allows_overloads() {
4569        let input = r#"
4570function normalize_email(email text) returns text language sql $$ SELECT lower(email) $$
4571function normalize_email(email text, fallback text) returns text language sql $$ SELECT lower(email) $$
4572"#;
4573        let schema = parse_qail(input).expect("function overloads should parse");
4574        assert_eq!(schema.functions.len(), 2);
4575    }
4576
4577    #[test]
4578    fn test_parse_trigger() {
4579        let input = "trigger trg_updated_at on users before update execute set_updated_at";
4580        let schema = parse_qail(input).unwrap();
4581        assert_eq!(schema.triggers.len(), 1);
4582        assert_eq!(schema.triggers[0].name, "trg_updated_at");
4583        assert_eq!(schema.triggers[0].table, "users");
4584        assert_eq!(schema.triggers[0].timing, "BEFORE");
4585        assert_eq!(schema.triggers[0].events, vec!["UPDATE"]);
4586        assert_eq!(schema.triggers[0].execute_function, "set_updated_at");
4587    }
4588
4589    #[test]
4590    fn test_parse_trigger_rejects_missing_event() {
4591        let input = "trigger trg_updated_at on users before execute set_updated_at";
4592        let err = parse_qail(input).expect_err("missing trigger event should fail");
4593        assert!(err.contains("trigger requires at least one event"));
4594    }
4595
4596    #[test]
4597    fn test_parse_trigger_rejects_invalid_timing() {
4598        let input = "trigger trg_updated_at on users during update execute set_updated_at";
4599        let err = parse_qail(input).expect_err("invalid trigger timing should fail");
4600        assert!(err.contains("unsupported trigger timing: DURING"));
4601    }
4602
4603    #[test]
4604    fn test_parse_trigger_rejects_invalid_event() {
4605        let input = "trigger trg_updated_at on users before banana execute set_updated_at";
4606        let err = parse_qail(input).expect_err("invalid trigger event should fail");
4607        assert!(err.contains("unsupported trigger event: BANANA"));
4608    }
4609
4610    #[test]
4611    fn test_parse_trigger_rejects_invalid_identifiers() {
4612        for (input, expected) in [
4613            (
4614                "trigger bad-name on users before update execute set_updated_at",
4615                "invalid trigger name 'bad-name'",
4616            ),
4617            (
4618                "trigger trg_updated_at on bad-table before update execute set_updated_at",
4619                "invalid trigger table 'bad-table'",
4620            ),
4621            (
4622                "trigger trg_updated_at on users before update execute bad-func",
4623                "invalid trigger function 'bad-func'",
4624            ),
4625            (
4626                "trigger trg_updated_at on users before update of bad-name execute set_updated_at",
4627                "invalid trigger update column 'bad-name'",
4628            ),
4629        ] {
4630            let err = parse_qail(input).expect_err("invalid trigger identifier should fail");
4631            assert!(err.contains(expected), "{err}");
4632        }
4633
4634        parse_qail("trigger trg_updated_at on app.users before update execute util.touch")
4635            .expect("schema-qualified trigger refs should parse");
4636    }
4637
4638    #[test]
4639    fn test_parse_trigger_rejects_trailing_content() {
4640        let input = "trigger trg_updated_at on users before update execute set_updated_at garbage";
4641        let err = parse_qail(input).expect_err("trailing trigger content should fail");
4642        assert!(err.contains("trailing content after trigger function"));
4643    }
4644
4645    #[test]
4646    fn test_parse_trigger_rejects_empty_update_of_columns() {
4647        for (input, expected) in [
4648            (
4649                "trigger trg_updated_at on users before update of , execute set_updated_at",
4650                "trigger update of contains an empty column",
4651            ),
4652            (
4653                "trigger trg_updated_at on users before update of name, execute set_updated_at",
4654                "trigger update of contains an empty column",
4655            ),
4656        ] {
4657            let err = parse_qail(input).expect_err("empty update-of columns should fail");
4658            assert!(err.contains(expected), "{err}");
4659        }
4660    }
4661
4662    #[test]
4663    fn test_parse_trigger_rejects_duplicate_update_of_columns() {
4664        let input =
4665            "trigger trg_updated_at on users before update of name,name execute set_updated_at";
4666        let err = parse_qail(input).expect_err("duplicate update-of columns should fail");
4667        assert!(err.contains("duplicate trigger update column 'name'"));
4668    }
4669
4670    #[test]
4671    fn test_parse_trigger_rejects_duplicate_events() {
4672        for input in [
4673            "trigger trg_updated_at on users before update or update execute set_updated_at",
4674            "trigger trg_updated_at on users before update of name or update execute set_updated_at",
4675        ] {
4676            let err = parse_qail(input).expect_err("duplicate trigger events should fail");
4677            assert!(err.contains("duplicate trigger event: UPDATE"), "{err}");
4678        }
4679    }
4680
4681    #[test]
4682    fn test_parse_trigger_rejects_duplicate_table_scoped_names() {
4683        let input = r#"
4684trigger trg_updated_at on users before update execute touch_users
4685trigger trg_updated_at on users after insert execute touch_users
4686"#;
4687        let err = parse_qail(input).expect_err("duplicate trigger should fail");
4688        assert!(err.contains("duplicate trigger declaration 'trg_updated_at on users'"));
4689    }
4690
4691    #[test]
4692    fn test_parse_trigger_allows_same_name_on_different_tables() {
4693        let input = r#"
4694trigger audit_change on users after update execute audit_user
4695trigger audit_change on posts after update execute audit_post
4696"#;
4697        let schema = parse_qail(input).expect("same trigger name on different tables should parse");
4698        assert_eq!(schema.triggers.len(), 2);
4699    }
4700
4701    #[test]
4702    fn test_parse_grant() {
4703        let input = "grant select, insert on users to app_role";
4704        let schema = parse_qail(input).unwrap();
4705        assert_eq!(schema.grants.len(), 1);
4706        assert_eq!(schema.grants[0].privileges.len(), 2);
4707        assert_eq!(schema.grants[0].on_object, "users");
4708        assert_eq!(schema.grants[0].to_role, "app_role");
4709        assert!(matches!(schema.grants[0].action, GrantAction::Grant));
4710    }
4711
4712    #[test]
4713    fn test_parse_revoke() {
4714        let input = "revoke all on users from public";
4715        let schema = parse_qail(input).unwrap();
4716        assert_eq!(schema.grants.len(), 1);
4717        assert!(matches!(schema.grants[0].action, GrantAction::Revoke));
4718        assert_eq!(schema.grants[0].on_object, "users");
4719        assert_eq!(schema.grants[0].to_role, "public");
4720    }
4721
4722    #[test]
4723    fn test_parse_grant_rejects_unknown_privilege() {
4724        let input = "grant selcet on users to app_role";
4725        let err = parse_qail(input).expect_err("unknown grant privilege should fail");
4726        assert!(err.contains("unknown grant/revoke privilege: SELCET"));
4727    }
4728
4729    #[test]
4730    fn test_parse_grant_rejects_duplicate_privileges() {
4731        let input = "grant select, SELECT on users to app_role";
4732        let err = parse_qail(input).expect_err("duplicate grant privilege should fail");
4733        assert!(err.contains("duplicate grant/revoke privilege: SELECT"));
4734    }
4735
4736    #[test]
4737    fn test_parse_grant_rejects_all_with_specific_privileges() {
4738        for input in [
4739            "grant all, select on users to app_role",
4740            "revoke select, all on users from app_role",
4741        ] {
4742            let err = parse_qail(input).expect_err("mixed ALL grant privileges should fail");
4743            assert!(err.contains("ALL privilege cannot be combined with specific privileges"));
4744        }
4745    }
4746
4747    #[test]
4748    fn test_parse_grant_rejects_missing_object_or_role() {
4749        let missing_object = "grant select on  to app_role";
4750        let err = parse_qail(missing_object).expect_err("missing grant object should fail");
4751        assert!(err.contains("grant/revoke object is required"));
4752
4753        let missing_role = "revoke select on users from ";
4754        let err = parse_qail(missing_role).expect_err("missing revoke role should fail");
4755        assert!(err.contains("grant/revoke role is required"));
4756    }
4757
4758    #[test]
4759    fn test_parse_full_phase3_schema() {
4760        let input = r#"
4761extension pgcrypto
4762
4763enum status { active, inactive }
4764
4765table users {
4766  id uuid primary_key
4767  name text not_null
4768  status text not_null
4769}
4770
4771view active_users $$ SELECT * FROM users WHERE status = 'active' $$
4772
4773function set_updated_at() returns trigger language plpgsql $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$
4774
4775trigger trg_updated on users before insert or update execute set_updated_at
4776
4777grant select on users to readonly_role
4778"#;
4779        let schema = parse_qail(input).unwrap();
4780        assert_eq!(schema.extensions.len(), 1);
4781        assert_eq!(schema.enums.len(), 1);
4782        assert_eq!(schema.tables.len(), 1);
4783        assert_eq!(schema.views.len(), 1);
4784        assert_eq!(schema.functions.len(), 1);
4785        assert_eq!(schema.triggers.len(), 1);
4786        assert_eq!(schema.grants.len(), 1);
4787    }
4788
4789    // ======================== Phase 4 Tests — New Parser Features ========================
4790
4791    #[test]
4792    fn test_parse_fk_actions() {
4793        let input = r#"
4794table orders {
4795  id uuid primary_key
4796  user_id uuid references users(id) on_delete cascade on_update restrict
4797}
4798"#;
4799        let schema = parse_qail(input).unwrap();
4800        let col = &schema.tables["orders"].columns[1];
4801        assert_eq!(col.name, "user_id");
4802        let fk = col.foreign_key.as_ref().unwrap();
4803        assert_eq!(fk.table, "users");
4804        assert_eq!(fk.column, "id");
4805        assert!(matches!(fk.on_delete, FkAction::Cascade));
4806        assert!(matches!(fk.on_update, FkAction::Restrict));
4807    }
4808
4809    #[test]
4810    fn test_parse_fk_references_dot_form() {
4811        let input = r#"
4812table orders {
4813  id uuid primary_key
4814  user_id uuid references(users.id) on_delete cascade on_update restrict
4815}
4816"#;
4817        let schema = parse_qail(input).unwrap();
4818        let fk = schema.tables["orders"].columns[1]
4819            .foreign_key
4820            .as_ref()
4821            .unwrap();
4822        assert_eq!(fk.table, "users");
4823        assert_eq!(fk.column, "id");
4824        assert!(matches!(fk.on_delete, FkAction::Cascade));
4825        assert!(matches!(fk.on_update, FkAction::Restrict));
4826    }
4827
4828    #[test]
4829    fn test_parse_fk_rejects_unknown_action() {
4830        let input = r#"
4831table orders {
4832  id uuid primary_key
4833  user_id uuid references users(id) on_delete cascad
4834}
4835"#;
4836        let err = parse_qail(input).expect_err("unknown foreign key action should fail");
4837        assert!(err.contains("unknown foreign key action: cascad"));
4838    }
4839
4840    #[test]
4841    fn test_parse_fk_rejects_missing_action() {
4842        let input = r#"
4843table orders {
4844  id uuid primary_key
4845  user_id uuid references users(id) on_delete
4846}
4847"#;
4848        let err = parse_qail(input).expect_err("missing foreign key action should fail");
4849        assert!(err.contains("on_delete requires a foreign key action"));
4850    }
4851
4852    #[test]
4853    fn test_parse_fk_rejects_duplicate_actions() {
4854        for (input, expected) in [
4855            (
4856                r#"
4857table orders {
4858  id uuid primary_key
4859  user_id uuid references users(id) on_delete cascade on_delete restrict
4860}
4861"#,
4862                "duplicate on_delete action",
4863            ),
4864            (
4865                r#"
4866table orders {
4867  id uuid primary_key
4868  user_id uuid references users(id) on_update cascade on_update restrict
4869}
4870"#,
4871                "duplicate on_update action",
4872            ),
4873        ] {
4874            let err = parse_qail(input).expect_err("duplicate foreign key action should fail");
4875            assert!(err.contains(expected), "{err}");
4876        }
4877    }
4878
4879    #[test]
4880    fn test_parse_fk_rejects_invalid_reference_target() {
4881        for (input, expected) in [
4882            (
4883                r#"
4884table orders {
4885  id uuid primary_key
4886  user_id uuid references
4887}
4888"#,
4889                "foreign key reference target is required",
4890            ),
4891            (
4892                r#"
4893table orders {
4894  id uuid primary_key
4895  user_id uuid referencesusers(id)
4896}
4897"#,
4898                "unknown column option 'referencesusers(id)' for column 'user_id'",
4899            ),
4900            (
4901                r#"
4902table orders {
4903  id uuid primary_key
4904  user_id uuid references users
4905}
4906"#,
4907                "invalid foreign key reference target: users",
4908            ),
4909            (
4910                r#"
4911table orders {
4912  id uuid primary_key
4913  user_id uuid references users()
4914}
4915"#,
4916                "invalid foreign key reference target: users()",
4917            ),
4918            (
4919                r#"
4920table orders {
4921  id uuid primary_key
4922  user_id uuid references users(i-d)
4923}
4924"#,
4925                "invalid foreign key reference target: users(i-d)",
4926            ),
4927            (
4928                r#"
4929table orders {
4930  id uuid primary_key
4931  user_id uuid references bad-table(id)
4932}
4933"#,
4934                "invalid foreign key reference target: bad-table(id)",
4935            ),
4936            (
4937                r#"
4938table orders {
4939  id uuid primary_key
4940  user_id uuid references(users.i-d)
4941}
4942"#,
4943                "invalid foreign key reference target: references(users.i-d)",
4944            ),
4945        ] {
4946            let err = parse_qail(input).expect_err("invalid foreign key target should fail");
4947            assert!(err.contains(expected), "{err}");
4948        }
4949    }
4950
4951    #[test]
4952    fn test_parse_fk_on_delete_only() {
4953        let input = r#"
4954table orders {
4955  id uuid primary_key
4956  operator_id uuid references operators(id) on_delete set_null
4957}
4958"#;
4959        let schema = parse_qail(input).unwrap();
4960        let col = &schema.tables["orders"].columns[1];
4961        let fk = col.foreign_key.as_ref().unwrap();
4962        assert!(matches!(fk.on_delete, FkAction::SetNull));
4963        assert!(matches!(fk.on_update, FkAction::NoAction));
4964    }
4965
4966    #[test]
4967    fn test_parse_check_between() {
4968        let input = r#"
4969table products {
4970  id uuid primary_key
4971  age int check(age between 0 200)
4972}
4973"#;
4974        let schema = parse_qail(input).unwrap();
4975        let col = &schema.tables["products"].columns[1];
4976        assert!(col.check.is_some());
4977        let expr = &col.check.as_ref().unwrap().expr;
4978        let CheckExpr::Between { column, low, high } = expr else {
4979            panic!("Expected Between, got {expr:?}");
4980        };
4981        assert_eq!(column, "age");
4982        assert_eq!(*low, 0);
4983        assert_eq!(*high, 200);
4984    }
4985
4986    #[test]
4987    fn test_parse_check_comparison() {
4988        let input = r#"
4989table products {
4990  id uuid primary_key
4991  score int check(score >= 0)
4992}
4993"#;
4994        let schema = parse_qail(input).unwrap();
4995        let col = &schema.tables["products"].columns[1];
4996        let expr = &col.check.as_ref().unwrap().expr;
4997        let CheckExpr::GreaterOrEqual { column, value } = expr else {
4998            panic!("Expected GreaterOrEqual, got {expr:?}");
4999        };
5000        assert_eq!(column, "score");
5001        assert_eq!(*value, 0);
5002    }
5003
5004    #[test]
5005    fn test_parse_postgres_casted_check_comparison() {
5006        let input = r#"
5007table partners {
5008  id uuid primary_key
5009  credit_balance decimal(15,2) not_null default 0 check(credit_balance >= (0)::numeric) check_name chk_credit_balance
5010  discount_percent decimal(5,2) not_null default 0 check((discount_percent >= (0)::numeric) AND (discount_percent <= (100)::numeric)) check_name chk_discount_percent
5011  sender_type text not_null check(sender_type = ANY (ARRAY['user'::text, 'bot'::text, 'agent'::text])) check_name app_chat_messages_sender_type_check
5012  client_type varchar(30) not_null default 'hotel'::character varying check((client_type)::text = ANY (ARRAY[('hotel'::character varying)::text, ('travel_agent'::character varying)::text])) check_name chk_client_type
5013  duration_hours int not_null check(duration_hours = ANY (ARRAY[8, 10, 12])) check_name duration_hours_check
5014  order_prefix text check((order_prefix)::text ~ '^[A-Z][A-Z0-9]{1,11}$'::text) check_name order_prefix_check
5015  origin_harbor_id uuid check(origin_harbor_id <> destination_harbor_id) check_name origin_destination_check
5016  end_date date check(end_date >= start_date) check_name end_after_start_check
5017  start_time time check((start_time)::time without time zone < (end_time)::time without time zone) check_name start_before_end_check
5018  start_date date check(start_date <= COALESCE(end_date, '2099-12-31'::date)) check_name open_ended_date_check
5019  module text check(module <> 'charter'::text) check_name module_not_charter_check
5020  slug text check((slug)::text = lower(btrim((slug)::text))) check_name slug_normalized_check
5021}
5022"#;
5023        let schema = parse_qail(input).unwrap();
5024        let credit = &schema.tables["partners"].columns[1];
5025        assert!(matches!(
5026            credit.check.as_ref().map(|check| &check.expr),
5027            Some(CheckExpr::GreaterOrEqual { column, value })
5028                if column == "credit_balance" && *value == 0
5029        ));
5030
5031        let discount = &schema.tables["partners"].columns[2];
5032        let CheckExpr::And(left, right) = &discount.check.as_ref().unwrap().expr else {
5033            panic!("Expected And, got {:?}", discount.check);
5034        };
5035        assert!(matches!(
5036            left.as_ref(),
5037            CheckExpr::GreaterOrEqual { column, value }
5038                if column == "discount_percent" && *value == 0
5039        ));
5040        assert!(matches!(
5041            right.as_ref(),
5042            CheckExpr::LessOrEqual { column, value }
5043                if column == "discount_percent" && *value == 100
5044        ));
5045
5046        let sender_type = &schema.tables["partners"].columns[3];
5047        assert!(matches!(
5048            sender_type.check.as_ref().map(|check| &check.expr),
5049            Some(CheckExpr::In { column, values })
5050                if column == "sender_type"
5051                    && values == &["user".to_string(), "bot".to_string(), "agent".to_string()]
5052        ));
5053
5054        let client_type = &schema.tables["partners"].columns[4];
5055        assert!(matches!(
5056            client_type.check.as_ref().map(|check| &check.expr),
5057            Some(CheckExpr::In { column, values })
5058                if column == "client_type"
5059                    && values == &["hotel".to_string(), "travel_agent".to_string()]
5060        ));
5061
5062        let duration_hours = &schema.tables["partners"].columns[5];
5063        assert!(matches!(
5064            duration_hours.check.as_ref().map(|check| &check.expr),
5065            Some(CheckExpr::InIntegers { column, values })
5066                if column == "duration_hours" && values == &[8, 10, 12]
5067        ));
5068
5069        let order_prefix = &schema.tables["partners"].columns[6];
5070        assert!(matches!(
5071            order_prefix.check.as_ref().map(|check| &check.expr),
5072            Some(CheckExpr::Regex { column, pattern })
5073                if column == "order_prefix" && pattern == "^[A-Z][A-Z0-9]{1,11}$"
5074        ));
5075
5076        let origin_harbor_id = &schema.tables["partners"].columns[7];
5077        assert!(matches!(
5078            origin_harbor_id.check.as_ref().map(|check| &check.expr),
5079            Some(CheckExpr::CompareColumns { left_column, op, right_column })
5080                if left_column == "origin_harbor_id"
5081                    && *op == CheckComparisonOp::NotEqual
5082                    && right_column == "destination_harbor_id"
5083        ));
5084
5085        let end_date = &schema.tables["partners"].columns[8];
5086        assert!(matches!(
5087            end_date.check.as_ref().map(|check| &check.expr),
5088            Some(CheckExpr::CompareColumns { left_column, op, right_column })
5089                if left_column == "end_date"
5090                    && *op == CheckComparisonOp::GreaterOrEqual
5091                    && right_column == "start_date"
5092        ));
5093
5094        let start_time = &schema.tables["partners"].columns[9];
5095        assert!(matches!(
5096            start_time.check.as_ref().map(|check| &check.expr),
5097            Some(CheckExpr::CompareColumns { left_column, op, right_column })
5098                if left_column == "start_time"
5099                    && *op == CheckComparisonOp::LessThan
5100                    && right_column == "end_time"
5101        ));
5102
5103        let start_date = &schema.tables["partners"].columns[10];
5104        assert!(matches!(
5105            start_date.check.as_ref().map(|check| &check.expr),
5106            Some(CheckExpr::CompareColumnToCoalesce {
5107                left_column,
5108                op,
5109                coalesce_column,
5110                fallback,
5111                fallback_cast,
5112            })
5113                if left_column == "start_date"
5114                    && *op == CheckComparisonOp::LessOrEqual
5115                    && coalesce_column == "end_date"
5116                    && fallback == "2099-12-31"
5117                    && fallback_cast.as_deref() == Some("date")
5118        ));
5119
5120        let module = &schema.tables["partners"].columns[11];
5121        assert!(matches!(
5122            module.check.as_ref().map(|check| &check.expr),
5123            Some(CheckExpr::TextCompare { column, op, value })
5124                if column == "module"
5125                    && *op == CheckComparisonOp::NotEqual
5126                    && value == "charter"
5127        ));
5128
5129        let slug = &schema.tables["partners"].columns[12];
5130        assert!(matches!(
5131            slug.check.as_ref().map(|check| &check.expr),
5132            Some(CheckExpr::LowerTrimEquals { column }) if column == "slug"
5133        ));
5134
5135        let rendered = super::super::schema::to_qail_string(&schema);
5136        assert!(rendered.contains("check(duration_hours = ANY (ARRAY[8, 10, 12]))"));
5137        assert!(rendered.contains("check(origin_harbor_id <> destination_harbor_id)"));
5138        assert!(rendered.contains("check(end_date >= start_date)"));
5139        assert!(rendered.contains("check(start_date <= COALESCE(end_date, '2099-12-31'::date))"));
5140        assert!(rendered.contains("check(module <> 'charter')"));
5141        assert!(rendered.contains("check(slug = lower(btrim(slug)))"));
5142    }
5143
5144    #[test]
5145    fn test_parse_check_in_round_trips_quoted_values() {
5146        let input = r#"
5147table tickets {
5148  status text check(status in [draft, "needs review", "card,bank", "quote "" ok", ""])
5149}
5150"#;
5151        let schema = parse_qail(input).unwrap();
5152        let col = &schema.tables["tickets"].columns[0];
5153        let CheckExpr::In { column, values } = &col.check.as_ref().unwrap().expr else {
5154            panic!("Expected In, got {:?}", col.check);
5155        };
5156
5157        assert_eq!(column, "status");
5158        assert_eq!(
5159            values,
5160            &[
5161                "draft".to_string(),
5162                "needs review".to_string(),
5163                "card,bank".to_string(),
5164                "quote \" ok".to_string(),
5165                String::new(),
5166            ]
5167        );
5168
5169        let rendered = super::super::schema::to_qail_string(&schema);
5170        let reparsed = parse_qail(&rendered).unwrap();
5171        let CheckExpr::In {
5172            column: reparsed_column,
5173            values: reparsed_values,
5174        } = &reparsed.tables["tickets"].columns[0]
5175            .check
5176            .as_ref()
5177            .unwrap()
5178            .expr
5179        else {
5180            panic!("Expected reparsed In");
5181        };
5182        assert_eq!(reparsed_column, column);
5183        assert_eq!(reparsed_values, values);
5184        assert!(
5185            rendered
5186                .contains(r#"status in [draft, "needs review", "card,bank", "quote "" ok", ""]"#)
5187        );
5188    }
5189
5190    #[test]
5191    fn test_parse_check_logical_operators_ignore_quoted_values() {
5192        let input = r#"
5193table tickets {
5194  status text check(status in ["needs and review", ready] and score >= 0)
5195  title text check(title ~ 'rock and roll')
5196  bad_regex text check(bad_regex ~ "not a sql text literal")
5197}
5198"#;
5199        let schema = parse_qail(input).unwrap();
5200        let status_check = &schema.tables["tickets"].columns[0]
5201            .check
5202            .as_ref()
5203            .unwrap()
5204            .expr;
5205        let CheckExpr::And(left, right) = status_check else {
5206            panic!("Expected And, got {status_check:?}");
5207        };
5208        assert!(matches!(
5209            left.as_ref(),
5210            CheckExpr::In { values, .. } if values == &["needs and review".to_string(), "ready".to_string()]
5211        ));
5212        assert!(matches!(
5213            right.as_ref(),
5214            CheckExpr::GreaterOrEqual { column, value } if column == "score" && *value == 0
5215        ));
5216
5217        let title_check = &schema.tables["tickets"].columns[1]
5218            .check
5219            .as_ref()
5220            .unwrap()
5221            .expr;
5222        assert!(matches!(
5223            title_check,
5224            CheckExpr::Regex { column, pattern } if column == "title" && pattern == "rock and roll"
5225        ));
5226
5227        let bad_regex_check = &schema.tables["tickets"].columns[2]
5228            .check
5229            .as_ref()
5230            .unwrap()
5231            .expr;
5232        assert!(matches!(
5233            bad_regex_check,
5234            CheckExpr::Sql(sql) if sql == "bad_regex ~ \"not a sql text literal\""
5235        ));
5236    }
5237
5238    #[test]
5239    fn test_parse_default_expression_with_spaces_and_cast() {
5240        let input = r#"
5241table idempotency_keys {
5242  expires_at timestamptz default (now() + '24:00:00'::interval)
5243}
5244"#;
5245        let schema = parse_qail(input).unwrap();
5246        let col = &schema.tables["idempotency_keys"].columns[0];
5247        assert_eq!(
5248            col.default.as_deref(),
5249            Some("(now() + '24:00:00'::interval)")
5250        );
5251    }
5252
5253    #[test]
5254    fn test_parse_default_rejects_missing_value() {
5255        let input = r#"
5256table idempotency_keys {
5257  expires_at timestamptz default
5258}
5259"#;
5260        let err = parse_qail(input).expect_err("missing default value should fail");
5261        assert!(err.contains("default requires a value for column 'expires_at'"));
5262    }
5263
5264    #[test]
5265    fn test_parse_check_expression_falls_back_to_raw() {
5266        let input = r#"
5267table vendors {
5268  name text check(char_length(btrim(name::text)) > 0)
5269}
5270"#;
5271        let schema = parse_qail(input).unwrap();
5272        schema
5273            .validate()
5274            .expect("raw expression checks should not invent column refs");
5275        let col = &schema.tables["vendors"].columns[0];
5276        let expr = &col.check.as_ref().unwrap().expr;
5277        match expr {
5278            CheckExpr::Sql(raw) => assert_eq!(raw, "char_length(btrim(name::text)) > 0"),
5279            other => panic!("Expected raw check expression, got {other:?}"),
5280        }
5281    }
5282
5283    #[test]
5284    fn test_parse_check_expression_keeps_function_coalesce_as_raw() {
5285        let input = r#"
5286table schedule_patterns {
5287  count int check(COALESCE(count, 1) > 0)
5288}
5289"#;
5290        let schema = parse_qail(input).unwrap();
5291        schema
5292            .validate()
5293            .expect("function expression checks should not invent column refs");
5294        let col = &schema.tables["schedule_patterns"].columns[0];
5295        let expr = &col.check.as_ref().unwrap().expr;
5296        match expr {
5297            CheckExpr::Sql(raw) => assert_eq!(raw, "COALESCE(count, 1) > 0"),
5298            other => panic!("Expected raw check expression, got {other:?}"),
5299        }
5300    }
5301
5302    #[test]
5303    fn test_parse_check_expression_ignores_parentheses_inside_literals() {
5304        let input = r#"
5305table vendors {
5306  code text check(code <> '(')
5307}
5308"#;
5309        let schema = parse_qail(input).unwrap();
5310        let col = &schema.tables["vendors"].columns[0];
5311        let expr = &col.check.as_ref().unwrap().expr;
5312        match expr {
5313            CheckExpr::TextCompare { column, op, value } => {
5314                assert_eq!(column, "code");
5315                assert!(matches!(op, CheckComparisonOp::NotEqual));
5316                assert_eq!(value, "(");
5317            }
5318            other => panic!("Expected text comparison check expression, got {other:?}"),
5319        }
5320    }
5321
5322    #[test]
5323    fn test_parse_check_rejects_invalid_expression_shape() {
5324        for (input, expected) in [
5325            (
5326                r#"
5327table tickets {
5328  id uuid primary_key
5329  score int check(score >= 0
5330}
5331"#,
5332                "unclosed check expression",
5333            ),
5334            (
5335                r#"
5336table tickets {
5337  id uuid primary_key
5338  score int check()
5339}
5340"#,
5341                "check expression is empty",
5342            ),
5343        ] {
5344            let err = parse_qail(input).expect_err("invalid check expression should fail");
5345            assert!(err.contains(expected), "{err}");
5346        }
5347    }
5348
5349    #[test]
5350    fn test_parse_check_name_rejects_invalid_shape() {
5351        for (input, expected) in [
5352            (
5353                r#"
5354table tickets {
5355  id uuid primary_key
5356  score int check_name positive_score
5357}
5358"#,
5359                "check_name requires a preceding check expression",
5360            ),
5361            (
5362                r#"
5363table tickets {
5364  id uuid primary_key
5365  score int check(score >= 0) check_name
5366}
5367"#,
5368                "check_name requires a name",
5369            ),
5370        ] {
5371            let err = parse_qail(input).expect_err("invalid check name shape should fail");
5372            assert!(err.contains(expected), "{err}");
5373        }
5374    }
5375
5376    #[test]
5377    fn test_parse_multiple_checks_on_one_column() {
5378        let input = r#"
5379table products {
5380  score int check(score >= 0) check_name score_min check(score <= 100) check_name score_max
5381}
5382"#;
5383
5384        let schema = parse_qail(input).expect("multiple checks should parse");
5385        let table = schema.tables.get("products").expect("products table");
5386        let score = table
5387            .columns
5388            .iter()
5389            .find(|column| column.name == "score")
5390            .expect("score column");
5391
5392        assert_eq!(
5393            score.check.as_ref().and_then(|check| check.name.as_deref()),
5394            Some("score_min")
5395        );
5396        assert_eq!(score.extra_checks.len(), 1);
5397        assert_eq!(score.extra_checks[0].name.as_deref(), Some("score_max"));
5398    }
5399
5400    #[test]
5401    fn test_parse_check_rejects_duplicate_name_for_same_check() {
5402        let input = r#"
5403table products {
5404  score int check(score >= 0) check_name score_min check_name score_min_again
5405}
5406"#;
5407
5408        let err = parse_qail(input).expect_err("duplicate check metadata should fail");
5409        assert!(
5410            err.contains("duplicate check_name for column 'score'"),
5411            "{err}"
5412        );
5413    }
5414
5415    #[test]
5416    fn test_parse_column_rejects_unknown_option() {
5417        let input = r#"
5418table users {
5419  id uuid primary_key
5420  email text uniq
5421}
5422"#;
5423        let err = parse_qail(input).expect_err("unknown column option should fail");
5424        assert!(err.contains("unknown column option 'uniq' for column 'email'"));
5425    }
5426
5427    #[test]
5428    fn test_parse_column_accepts_multi_word_type() {
5429        let input = r#"
5430table docks {
5431  latitude DOUBLE PRECISION
5432  created_at TIMESTAMP WITH TIME ZONE
5433}
5434"#;
5435        let schema = parse_qail(input).expect("multi-word types should parse");
5436        let table = schema
5437            .tables
5438            .get("docks")
5439            .expect("docks table should parse");
5440        assert_eq!(table.columns[0].data_type, ColumnType::Float);
5441        assert_eq!(table.columns[1].data_type, ColumnType::Timestamptz);
5442    }
5443
5444    #[test]
5445    fn test_parse_column_rejects_conflicting_nullability() {
5446        for (input, expected) in [
5447            (
5448                r#"
5449table users {
5450  email text not_null nullable
5451}
5452"#,
5453                "conflicting nullability options 'not_null' and 'nullable' for column 'email'",
5454            ),
5455            (
5456                r#"
5457table users {
5458  email text nullable not_null
5459}
5460"#,
5461                "conflicting nullability options 'nullable' and 'not_null' for column 'email'",
5462            ),
5463            (
5464                r#"
5465table users {
5466  id uuid primary_key nullable
5467}
5468"#,
5469                "nullable conflicts with primary_key for column 'id'",
5470            ),
5471            (
5472                r#"
5473table users {
5474  id uuid nullable primary_key
5475}
5476"#,
5477                "primary_key conflicts with nullable for column 'id'",
5478            ),
5479        ] {
5480            let err = parse_qail(input).expect_err("conflicting nullability should fail");
5481            assert!(err.contains(expected), "{err}");
5482        }
5483    }
5484
5485    #[test]
5486    fn test_parse_column_rejects_duplicate_default() {
5487        let input = r#"
5488table users {
5489  status text default 'draft' default 'active'
5490}
5491"#;
5492        let err = parse_qail(input).expect_err("duplicate default should fail");
5493        assert!(err.contains("duplicate default option for column 'status'"));
5494    }
5495
5496    #[test]
5497    fn test_parse_column_rejects_duplicate_key_options() {
5498        for (input, expected) in [
5499            (
5500                r#"
5501table users {
5502  id uuid primary_key primary_key
5503}
5504"#,
5505                "duplicate primary_key option for column 'id'",
5506            ),
5507            (
5508                r#"
5509table users {
5510  email text unique unique
5511}
5512"#,
5513                "duplicate unique option for column 'email'",
5514            ),
5515        ] {
5516            let err = parse_qail(input).expect_err("duplicate key option should fail");
5517            assert!(err.contains(expected), "{err}");
5518        }
5519    }
5520
5521    #[test]
5522    fn test_parse_column_rejects_duplicate_generated_options() {
5523        let input = r#"
5524table users {
5525  id bigint generated_identity generated_by_default_identity
5526}
5527"#;
5528        let err = parse_qail(input).expect_err("duplicate generated option should fail");
5529        assert!(err.contains("duplicate generated option for column 'id'"));
5530    }
5531
5532    #[test]
5533    fn test_parse_enum_column_type() {
5534        let input = r#"
5535enum ticket_status { draft, active, cancelled }
5536
5537table tickets {
5538  id uuid primary_key
5539  status ticket_status default 'draft'
5540}
5541"#;
5542        let schema = parse_qail(input).unwrap();
5543        assert_eq!(schema.enums.len(), 1);
5544        let col = &schema.tables["tickets"].columns[1];
5545        assert_eq!(col.name, "status");
5546        let ColumnType::Enum { name, values } = &col.data_type else {
5547            panic!("Expected Enum type, got {:?}", col.data_type);
5548        };
5549        assert_eq!(name, "ticket_status");
5550        assert_eq!(values, &["draft", "active", "cancelled"]);
5551        assert_eq!(col.default.as_deref(), Some("'draft'"));
5552    }
5553
5554    #[test]
5555    fn test_generated_columns_roundtrip_and_emit_sql() {
5556        use crate::transpiler::{Dialect, ToSql};
5557
5558        let input = r#"
5559table people {
5560  first_name text
5561  last_name text
5562  full_name text generated_stored(first_name || ' ' || last_name)
5563  row_seq bigint generated_by_default_identity
5564}
5565"#;
5566        let schema = parse_qail(input).expect("generated columns should parse");
5567        let table = &schema.tables["people"];
5568
5569        assert!(matches!(
5570            table.columns[2].generated.as_ref(),
5571            Some(Generated::AlwaysStored(expr)) if expr == "first_name || ' ' || last_name"
5572        ));
5573        assert!(matches!(
5574            table.columns[3].generated.as_ref(),
5575            Some(Generated::ByDefaultIdentity)
5576        ));
5577
5578        let rendered = super::super::schema::to_qail_string(&schema);
5579        assert!(rendered.contains("generated_stored(first_name || ' ' || last_name)"));
5580        assert!(rendered.contains("generated_by_default_identity"));
5581
5582        let reparsed = parse_qail(&rendered).expect("rendered generated columns should parse");
5583        assert!(matches!(
5584            reparsed.tables["people"].columns[2].generated.as_ref(),
5585            Some(Generated::AlwaysStored(expr)) if expr == "first_name || ' ' || last_name"
5586        ));
5587        assert!(matches!(
5588            reparsed.tables["people"].columns[3].generated.as_ref(),
5589            Some(Generated::ByDefaultIdentity)
5590        ));
5591
5592        let sql = super::super::schema::schema_to_commands(&reparsed)
5593            .into_iter()
5594            .map(|cmd| cmd.to_sql_with_dialect(Dialect::Postgres))
5595            .collect::<Vec<_>>()
5596            .join("\n");
5597        assert!(sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"));
5598        assert!(sql.contains("GENERATED BY DEFAULT AS IDENTITY"));
5599    }
5600
5601    #[test]
5602    fn test_parse_generated_stored_ignores_parentheses_inside_literals() {
5603        let input = r#"
5604table labels {
5605  raw text
5606  decorated text generated_stored(raw || '(')
5607}
5608"#;
5609        let schema = parse_qail(input).expect("generated expression should parse");
5610        let table = &schema.tables["labels"];
5611
5612        assert!(matches!(
5613            table.columns[1].generated.as_ref(),
5614            Some(Generated::AlwaysStored(expr)) if expr == "raw || '('"
5615        ));
5616    }
5617
5618    #[test]
5619    fn test_parse_generated_stored_rejects_invalid_expression() {
5620        for (input, expected) in [
5621            (
5622                r#"
5623table invoices {
5624  id uuid primary_key
5625  total numeric generated_stored(subtotal + tax
5626}
5627"#,
5628                "unclosed generated_stored expression",
5629            ),
5630            (
5631                r#"
5632table invoices {
5633  id uuid primary_key
5634  total numeric generated_stored()
5635}
5636"#,
5637                "generated_stored expression is empty",
5638            ),
5639        ] {
5640            let err = parse_qail(input).expect_err("invalid generated expression should fail");
5641            assert!(err.contains(expected), "{err}");
5642        }
5643    }
5644
5645    #[test]
5646    fn test_parse_roundtrip_all_features() {
5647        let input = r#"
5648extension pgcrypto
5649
5650enum payment_method { card, va, qris, cash }
5651
5652sequence invoice_counter { start 1000 increment 1 }
5653
5654table orders {
5655  id uuid primary_key default gen_random_uuid()
5656  method payment_method not_null default 'card'
5657  user_id uuid references users(id) on_delete cascade
5658  score int check(score >= 0)
5659  age int check(age between 0 200)
5660  enable_rls
5661  force_rls
5662}
5663"#;
5664        let schema = parse_qail(input).unwrap();
5665        assert_eq!(schema.extensions.len(), 1);
5666        assert_eq!(schema.enums.len(), 1);
5667        assert_eq!(schema.sequences.len(), 1);
5668        assert_eq!(schema.tables.len(), 1);
5669
5670        let table = &schema.tables["orders"];
5671        assert!(table.enable_rls);
5672        assert!(table.force_rls);
5673
5674        // Enum column
5675        let method = &table.columns[1];
5676        assert!(
5677            matches!(&method.data_type, ColumnType::Enum { name, .. } if name == "payment_method")
5678        );
5679        assert_eq!(method.default.as_deref(), Some("'card'"));
5680
5681        // FK with cascade
5682        let user_id = &table.columns[2];
5683        let fk = user_id.foreign_key.as_ref().unwrap();
5684        assert!(matches!(fk.on_delete, FkAction::Cascade));
5685
5686        // CHECK >= 0
5687        let score = &table.columns[3];
5688        assert!(matches!(
5689            &score.check.as_ref().unwrap().expr,
5690            CheckExpr::GreaterOrEqual { .. }
5691        ));
5692
5693        // CHECK between
5694        let age = &table.columns[4];
5695        assert!(matches!(
5696            &age.check.as_ref().unwrap().expr,
5697            CheckExpr::Between { .. }
5698        ));
5699    }
5700
5701    #[test]
5702    fn test_parse_booking_migration() {
5703        let input = r#"
5704table booking_orders {
5705  id                    uuid primary_key default gen_random_uuid()
5706  hold_id               uuid nullable
5707  connection_id         uuid nullable
5708  voyage_id             uuid nullable
5709  operator_id           uuid not_null
5710  status                text not_null default 'Draft'
5711  total_fare            bigint not_null
5712  currency              text not_null default 'IDR'
5713  nationality           text not_null default 'indo'
5714  pax_breakdown         jsonb not_null default '{}'
5715  contact_info          jsonb not_null default '{}'
5716  pricing_breakdown     jsonb nullable
5717  passenger_details     jsonb nullable default '[]'
5718  connection_snapshot   jsonb nullable
5719  invoice_number        text nullable unique
5720  booking_number        text nullable
5721  metadata              jsonb nullable
5722  user_id               uuid nullable
5723  agent_id              uuid nullable
5724  created_at            timestamptz not_null default now()
5725  updated_at            timestamptz not_null default now()
5726
5727  enable_rls
5728  force_rls
5729}
5730
5731index idx_booking_orders_operator on booking_orders (operator_id)
5732index idx_booking_orders_status on booking_orders (status)
5733index idx_booking_orders_user on booking_orders (user_id)
5734"#;
5735        let schema = parse_qail(input).expect("parse_qail should succeed for booking migration");
5736        assert_eq!(schema.tables.len(), 1);
5737        let table = &schema.tables["booking_orders"];
5738        assert!(table.enable_rls);
5739        assert!(table.force_rls);
5740        assert_eq!(table.columns.len(), 21);
5741        assert_eq!(schema.indexes.len(), 3);
5742    }
5743
5744    #[test]
5745    fn parse_text_array_column_does_not_absorb_default_into_type() {
5746        let input = r#"
5747table agents {
5748  id uuid primary_key
5749  verticals TEXT[] not_null default '{}'::text[]
5750}
5751"#;
5752        let schema = parse_qail(input).expect("array column with default should parse");
5753        let table = &schema.tables["agents"];
5754        let verticals = table
5755            .columns
5756            .iter()
5757            .find(|col| col.name == "verticals")
5758            .expect("verticals column should exist");
5759
5760        assert_eq!(
5761            verticals.data_type,
5762            ColumnType::Array(Box::new(ColumnType::Text))
5763        );
5764        assert!(!verticals.nullable);
5765        assert_eq!(verticals.default.as_deref(), Some("'{}'::text[]"));
5766    }
5767
5768    #[test]
5769    fn test_parse_rejects_invalid_primary_key_type() {
5770        let input = r#"
5771table bad_pk {
5772  id jsonb primary_key
5773}
5774"#;
5775        let err = parse_qail(input).expect_err("JSONB primary key should be rejected");
5776        assert!(err.contains("cannot be a primary key"));
5777    }
5778
5779    #[test]
5780    fn test_parse_accepts_date_primary_key_type() {
5781        let input = r#"
5782table daily_stats {
5783  date date primary_key
5784}
5785"#;
5786        let schema = parse_qail(input).expect("DATE primary key should be accepted");
5787        let table = &schema.tables["daily_stats"];
5788        assert_eq!(table.columns.len(), 1);
5789        assert!(table.columns[0].primary_key);
5790    }
5791
5792    #[test]
5793    fn test_parse_policy_fallback_keeps_unsupported_expression() {
5794        let input = r#"
5795table seo_comparisons {
5796  id uuid primary_key
5797}
5798
5799policy seo_comparisons_admin on seo_comparisons for all
5800  using $$ status = 'cancelled'::text $$
5801"#;
5802
5803        let schema = parse_qail(input).expect("policy parser should fall back to raw expr");
5804        let policy = schema
5805            .policies
5806            .iter()
5807            .find(|p| p.name == "seo_comparisons_admin")
5808            .expect("policy missing");
5809
5810        match policy.using.as_ref() {
5811            Some(Expr::Named(expr)) => {
5812                assert!(expr.contains("status = 'cancelled'::text"));
5813            }
5814            other => panic!("expected fallback Expr::Named, got {:?}", other),
5815        }
5816    }
5817
5818    #[test]
5819    fn test_parse_policy_preserves_role_and_restrictive_from_roundtrip_header() {
5820        let input = r#"
5821table docs {
5822  id uuid primary_key
5823}
5824
5825policy docs_select on docs for select to app_user restrictive
5826  using $$ owner_id = current_setting('app.current_user_id')::uuid $$
5827"#;
5828
5829        let schema = parse_qail(input).expect("policy should parse");
5830        let policy = schema.policies.first().expect("policy missing");
5831
5832        assert_eq!(policy.target, PolicyTarget::Select);
5833        assert_eq!(policy.role.as_deref(), Some("app_user"));
5834        assert_eq!(policy.permissiveness, PolicyPermissiveness::Restrictive);
5835
5836        let sql = crate::transpiler::policy::create_policy_sql(policy);
5837        assert!(sql.contains("AS RESTRICTIVE"));
5838        assert!(sql.contains("FOR SELECT"));
5839        assert!(sql.contains("TO app_user"));
5840
5841        let rendered = super::super::schema::to_qail_string(&schema);
5842        assert!(rendered.contains("policy docs_select on docs for select to app_user restrictive"));
5843
5844        let reparsed = parse_qail(&rendered).expect("rendered policy should parse");
5845        let reparsed_policy = reparsed.policies.first().expect("reparsed policy missing");
5846        assert_eq!(reparsed_policy.target, PolicyTarget::Select);
5847        assert_eq!(reparsed_policy.role.as_deref(), Some("app_user"));
5848        assert_eq!(
5849            reparsed_policy.permissiveness,
5850            PolicyPermissiveness::Restrictive
5851        );
5852    }
5853
5854    #[test]
5855    fn test_parse_policy_preserves_split_line_role_and_permissiveness_clauses() {
5856        let input = r#"
5857table docs {
5858  id uuid primary_key
5859}
5860
5861policy docs_select on docs
5862  for select
5863  restrictive
5864  to app_user
5865  using $$ owner_id = current_setting('app.current_user_id')::uuid $$
5866"#;
5867
5868        let schema = parse_qail(input).expect("policy should parse");
5869        let policy = schema.policies.first().expect("policy missing");
5870
5871        assert_eq!(policy.target, PolicyTarget::Select);
5872        assert_eq!(policy.role.as_deref(), Some("app_user"));
5873        assert_eq!(policy.permissiveness, PolicyPermissiveness::Restrictive);
5874    }
5875
5876    #[test]
5877    fn test_parse_policy_rejects_unknown_continuation_lines() {
5878        let input = r#"
5879policy docs_select on docs
5880  usng $$ true $$
5881"#;
5882        let err = parse_qail(input).expect_err("unknown policy continuation should fail");
5883        assert!(err.contains("Unknown policy continuation line: usng $$ true $$"));
5884    }
5885
5886    #[test]
5887    fn test_parse_policy_rejects_duplicate_table_scoped_names() {
5888        let input = r#"
5889policy tenant_isolation on docs for select
5890policy tenant_isolation on docs for update
5891"#;
5892        let err = parse_qail(input).expect_err("duplicate policy should fail");
5893        assert!(err.contains("duplicate policy declaration 'tenant_isolation on docs'"));
5894    }
5895
5896    #[test]
5897    fn test_parse_policy_allows_same_name_on_different_tables() {
5898        let input = r#"
5899policy tenant_isolation on docs for select
5900policy tenant_isolation on folders for select
5901"#;
5902        let schema = parse_qail(input).expect("same policy name on different tables should parse");
5903        assert_eq!(schema.policies.len(), 2);
5904    }
5905
5906    #[test]
5907    fn test_parse_rejects_unclosed_resource_block() {
5908        let input = r#"
5909bucket avatars {
5910  provider s3
5911  region "ap-southeast-1"
5912"#;
5913
5914        let err = parse_qail(input).expect_err("unclosed resource block should be rejected");
5915        assert!(err.contains("Unclosed bucket resource block"));
5916    }
5917
5918    #[test]
5919    fn test_parse_resource_rejects_trailing_content_without_block() {
5920        let err = parse_qail("bucket avatars provider s3")
5921            .expect_err("resource trailing content should fail");
5922        assert!(err.contains("Trailing content after bucket resource name"));
5923    }
5924
5925    #[test]
5926    fn test_parse_resource_rejects_property_without_value() {
5927        let input = r#"
5928bucket avatars {
5929  provider
5930}
5931"#;
5932        let err = parse_qail(input).expect_err("resource property without value should fail");
5933        assert!(err.contains("Resource property 'provider' in 'avatars' requires a value"));
5934    }
5935
5936    #[test]
5937    fn test_parse_resource_rejects_duplicate_properties() {
5938        let input = r#"
5939bucket avatars {
5940  provider s3
5941  provider gcs
5942}
5943"#;
5944        let err = parse_qail(input).expect_err("duplicate resource properties should fail");
5945        assert!(err.contains("Duplicate resource property 'provider' in 'avatars'"));
5946    }
5947
5948    #[test]
5949    fn test_parse_resource_rejects_duplicate_names() {
5950        let input = r#"
5951bucket notifications { provider s3 }
5952queue notifications { provider sqs }
5953"#;
5954        let err = parse_qail(input).expect_err("duplicate resources should fail");
5955        assert!(err.contains("duplicate resource declaration 'notifications'"));
5956    }
5957
5958    #[test]
5959    fn test_parse_resource_preserves_quoted_property_values() {
5960        let input = r#"
5961bucket avatars {
5962  provider s3
5963  display_name "Profile Images"
5964  region 'ap southeast 1'
5965}
5966"#;
5967
5968        let schema = parse_qail(input).expect("resource should parse");
5969        let resource = schema.resources.first().expect("resource missing");
5970
5971        assert_eq!(resource.provider.as_deref(), Some("s3"));
5972        assert_eq!(
5973            resource.properties.get("display_name").map(String::as_str),
5974            Some("Profile Images")
5975        );
5976        assert_eq!(
5977            resource.properties.get("region").map(String::as_str),
5978            Some("ap southeast 1")
5979        );
5980    }
5981
5982    #[test]
5983    fn test_parse_resource_ignores_braces_inside_quoted_values() {
5984        let input = r#"
5985bucket avatars {
5986  provider s3
5987  label "Profile } Images"
5988}
5989"#;
5990
5991        let schema = parse_qail(input).expect("resource should parse");
5992        let resource = schema.resources.first().expect("resource missing");
5993
5994        assert_eq!(
5995            resource.properties.get("label").map(String::as_str),
5996            Some("Profile } Images")
5997        );
5998    }
5999
6000    #[test]
6001    fn test_parse_rejects_invalid_unique_type() {
6002        let input = r#"
6003table bad_unique {
6004  payload jsonb unique
6005}
6006"#;
6007        let err = parse_qail(input).expect_err("JSONB unique should be rejected");
6008        assert!(err.contains("cannot have UNIQUE"));
6009    }
6010
6011    #[test]
6012    fn test_parse_rejects_unknown_column_type() {
6013        let input = r#"
6014table bad_type {
6015  data mysterytype
6016}
6017"#;
6018        let err = parse_qail(input).expect_err("unknown type should be rejected");
6019        assert!(err.contains("Unknown column type"));
6020    }
6021}