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