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