1use 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
31pub 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 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 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
188pub 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
203fn 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 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 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 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
320fn 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 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 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
619fn 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 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
789fn parse_rename(line: &str) -> Result<MigrationHint, String> {
791 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
813fn parse_transform(line: &str) -> Result<MigrationHint, String> {
815 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
837fn parse_drop(line: &str) -> Result<MigrationHint, String> {
839 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
857fn 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
932fn 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
1018fn 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
1173fn 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
1337fn parse_multi_column_fk(line: &str) -> Result<MultiColumnForeignKey, String> {
1340 let rest = line.strip_prefix("foreign_key").unwrap_or(line).trim();
1341
1342 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 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 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
1495fn 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 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
1554fn 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 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
1962fn 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 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 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
2092fn 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 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 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
2173fn 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
2327pub 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 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 if let Some(expr) = parse_check_in_expr(s) {
2357 return Some(expr);
2358 }
2359
2360 if let Some(expr) = parse_postgres_any_array_check_expr(s) {
2363 return Some(expr);
2364 }
2365
2366 if let Some(expr) = parse_postgres_regex_check_expr(s) {
2368 return Some(expr);
2369 }
2370
2371 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 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 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 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 if let Some(expr) = parse_column_comparison_check_expr(s) {
2436 return Some(expr);
2437 }
2438
2439 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 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
3000fn 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 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 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 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 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 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
3179fn parse_policy<'a, I: Iterator<Item = &'a str>>(
3191 first_line: &str,
3192 lines: &mut std::iter::Peekable<I>,
3193) -> Result<RlsPolicy, String> {
3194 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 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 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 if !next_line.starts_with(" ") && !next_line.starts_with('\t') {
3228 break;
3229 }
3230
3231 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 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
3323fn 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 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 Ok(after_open[..pos].trim().to_string())
3337 } else {
3338 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 #[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 #[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 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 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 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 #[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 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 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 let score = &table.columns[3];
5688 assert!(matches!(
5689 &score.check.as_ref().unwrap().expr,
5690 CheckExpr::GreaterOrEqual { .. }
5691 ));
5692
5693 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}