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 if matches!(line, "enable_rls" | "force_rls") {
454 current_rls_flag = true;
455 continue;
456 }
457
458 let parts: Vec<&str> = line.split_whitespace().collect();
459 if let Some(col_name) = parts.first() {
460 if !is_build_identifier(col_name) {
461 let table_name = current_table.as_deref().unwrap_or("<unknown>");
462 return Err(format!(
463 "Invalid column name '{}' in table '{}'",
464 col_name, table_name
465 ));
466 }
467 if current_columns.contains_key(*col_name) {
468 let table_name = current_table.as_deref().unwrap_or("<unknown>");
469 return Err(format!(
470 "duplicate column '{}' in table '{}'",
471 col_name, table_name
472 ));
473 }
474 let table_name = current_table.as_deref().unwrap_or("<unknown>");
475 let Some((col_type, type_end)) =
476 parse_build_column_type_prefix(&parts, &enum_types)
477 else {
478 let Some(col_type_str) = parts.get(1).copied() else {
479 return Err(format!(
480 "Missing type for column '{}' in table '{}'",
481 col_name, table_name
482 ));
483 };
484 return Err(format!(
485 "Unknown column type '{}' for column '{}' in table '{}'",
486 col_type_str, col_name, table_name
487 ));
488 };
489 current_columns.insert(col_name.to_string(), col_type);
490
491 let mut policy = "Public".to_string();
493 let mut seen_protected = false;
494 let mut seen_column_options = HashSet::new();
495 let mut nullability_option: Option<&str> = None;
496 let mut generated_option: Option<&str> = None;
497 let mut has_foreign_key = false;
498 let mut seen_fk_actions = HashSet::new();
499
500 let mut i = type_end;
501 while i < parts.len() {
502 let part = parts[i];
503 if part == "protected" {
504 if seen_protected {
505 return Err(format!(
506 "duplicate protected option for column '{}' in table '{}'",
507 col_name, table_name
508 ));
509 }
510 seen_protected = true;
511 policy = "Protected".to_string();
512 } else if matches!(
513 part,
514 "primary_key"
515 | "not_null"
516 | "nullable"
517 | "unique"
518 | "generated_identity"
519 | "generated_by_default_identity"
520 ) {
521 if !seen_column_options.insert(part) {
522 return Err(format!(
523 "duplicate column option '{}' for column '{}' in table '{}'",
524 part, col_name, table_name
525 ));
526 }
527 if matches!(part, "not_null" | "nullable") {
528 if let Some(existing) = nullability_option {
529 return Err(format!(
530 "conflicting nullability options '{}' and '{}' for column '{}' in table '{}'",
531 existing, part, col_name, table_name
532 ));
533 }
534 nullability_option = Some(part);
535 }
536 if matches!(
537 part,
538 "generated_identity" | "generated_by_default_identity"
539 ) {
540 if let Some(existing) = generated_option {
541 return Err(format!(
542 "conflicting generated options '{}' and '{}' for column '{}' in table '{}'",
543 existing, part, col_name, table_name
544 ));
545 }
546 generated_option = Some(part);
547 }
548 } else if part == "default" {
550 if i + 1 >= parts.len() {
551 return Err(format!(
552 "default requires a value for column '{}' in table '{}'",
553 col_name, table_name
554 ));
555 }
556 break;
557 } else if part.starts_with("default=")
558 || part.starts_with("default:")
559 || part.starts_with("generated_stored(")
560 || part.starts_with("check(")
561 {
562 break;
563 } else if let Some(ref_spec) = part.strip_prefix("ref:") {
564 let (ref_table, ref_column) =
566 parse_build_ref_spec(ref_spec, col_name, table_name)?;
567 push_build_foreign_key(
568 &mut current_fks,
569 col_name,
570 ref_table,
571 ref_column,
572 table_name,
573 )?;
574 has_foreign_key = true;
575 } else if part == "references" {
576 if i + 1 >= parts.len() {
577 return Err(format!(
578 "foreign key reference target is required for column '{}' in table '{}'",
579 col_name, table_name
580 ));
581 }
582 i += 1;
583 let (ref_table, ref_column) =
584 parse_build_references_target(parts[i], col_name, table_name)?;
585 push_build_foreign_key(
586 &mut current_fks,
587 col_name,
588 ref_table,
589 ref_column,
590 table_name,
591 )?;
592 has_foreign_key = true;
593 } else if let Some(ref_target) = part.strip_prefix("references") {
594 let (ref_table, ref_column) =
595 parse_build_references_target(ref_target, col_name, table_name)?;
596 push_build_foreign_key(
597 &mut current_fks,
598 col_name,
599 ref_table,
600 ref_column,
601 table_name,
602 )?;
603 has_foreign_key = true;
604 } else if matches!(part, "on_delete" | "on_update") {
605 if !has_foreign_key {
606 return Err(format!(
607 "{} requires a preceding foreign key for column '{}' in table '{}'",
608 part, col_name, table_name
609 ));
610 }
611 if !seen_fk_actions.insert(part) {
612 return Err(format!(
613 "duplicate {} action for column '{}' in table '{}'",
614 part, col_name, table_name
615 ));
616 }
617 if i + 1 >= parts.len() {
618 return Err(format!(
619 "{} requires a foreign key action for column '{}' in table '{}'",
620 part, col_name, table_name
621 ));
622 }
623 i += 1;
624 if !is_build_fk_action(parts[i]) {
625 return Err(format!(
626 "unknown foreign key action '{}' for column '{}' in table '{}'",
627 parts[i], col_name, table_name
628 ));
629 }
630 } else if part == "check_name" {
631 if i + 1 >= parts.len() {
632 return Err(format!(
633 "check_name requires a name for column '{}' in table '{}'",
634 col_name, table_name
635 ));
636 }
637 i += 1;
638 } else {
639 return Err(format!(
640 "Unknown column option '{}' for column '{}' in table '{}'",
641 part, col_name, table_name
642 ));
643 }
644 i += 1;
645 }
646 current_policies.insert(col_name.to_string(), policy);
647 }
648 }
649 }
650
651 if let Some(table_name) = current_table.take() {
652 return Err(format!(
653 "Unclosed table definition for '{}': expected closing '}}'",
654 table_name
655 ));
656 }
657
658 Ok(schema)
659 }
660
661 pub fn resolve_table_name(&self, name: &str) -> Option<&str> {
667 if let Some((key, _)) = self.tables.get_key_value(name) {
668 return Some(key.as_str());
669 }
670 if let Some(view) = self.views.get(name) {
671 return Some(view.as_str());
672 }
673
674 if let Some(bare) = name.strip_prefix("public.") {
675 if bare.is_empty() {
676 return None;
677 }
678 if let Some((key, _)) = self.tables.get_key_value(bare) {
679 return Some(key.as_str());
680 }
681 if let Some(view) = self.views.get(bare) {
682 return Some(view.as_str());
683 }
684 return None;
685 }
686
687 if !name.contains('.') {
688 let qualified = format!("public.{name}");
689 if let Some((key, _)) = self.tables.get_key_value(&qualified) {
690 return Some(key.as_str());
691 }
692 if let Some(view) = self.views.get(&qualified) {
693 return Some(view.as_str());
694 }
695 }
696
697 None
698 }
699
700 pub fn has_table(&self, name: &str) -> bool {
702 self.resolve_table_name(name).is_some()
703 }
704
705 pub fn rls_tables(&self) -> Vec<&str> {
707 self.tables
708 .iter()
709 .filter(|(_, ts)| ts.rls_enabled)
710 .map(|(name, _)| name.as_str())
711 .collect()
712 }
713
714 pub fn is_rls_table(&self, name: &str) -> bool {
716 self.table(name).is_some_and(|t| t.rls_enabled)
717 }
718
719 pub fn table(&self, name: &str) -> Option<&TableSchema> {
721 let resolved = self.resolve_table_name(name)?;
722 self.tables.get(resolved)
723 }
724
725 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
730 use std::fs;
731
732 let dir = Path::new(migrations_dir);
733 if !dir.exists() {
734 return Ok(0); }
736
737 let mut merged_count = 0;
738
739 let entries =
741 fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
742
743 for entry in entries.flatten() {
744 let path = entry.path();
745
746 let mut migration_files: Vec<std::path::PathBuf> = Vec::new();
751 if path.is_dir() {
752 let up_qail = path.join("up.qail");
753 let up_sql = path.join("up.sql");
754 if up_qail.exists() {
755 migration_files.push(up_qail);
756 } else if up_sql.exists() {
757 migration_files.push(up_sql);
758 } else {
759 for phase in ["expand", "backfill", "contract"] {
760 let qail = path.join(format!("{phase}.qail"));
761 let sql = path.join(format!("{phase}.sql"));
762 if qail.exists() {
763 migration_files.push(qail);
764 } else if sql.exists() {
765 migration_files.push(sql);
766 }
767 }
768 if migration_files.is_empty() {
769 continue;
770 }
771 }
772 } else if path.extension().is_some_and(|e| e == "qail" || e == "sql") {
773 migration_files.push(path.clone());
774 } else {
775 continue;
776 }
777
778 for migration_file in migration_files {
779 if !migration_file.exists() {
780 continue;
781 }
782 let content = fs::read_to_string(&migration_file)
783 .map_err(|e| format!("Failed to read {}: {}", migration_file.display(), e))?;
784
785 if migration_file.extension().is_some_and(|ext| ext == "qail") {
786 merged_count += self.parse_qail_migration(&content).map_err(|e| {
787 format!(
788 "Failed to parse native migration {}: {}",
789 migration_file.display(),
790 e
791 )
792 })?;
793 } else {
794 merged_count += self.parse_sql_migration(&content);
795 }
796 }
797 }
798
799 Ok(merged_count)
800 }
801
802 pub(crate) fn parse_qail_migration(&mut self, qail: &str) -> Result<usize, String> {
804 let parsed = Schema::parse(qail)?;
805 let mut changes = 0usize;
806
807 for (table_name, parsed_table) in parsed.tables {
808 if let Some(existing) = self.tables.get_mut(&table_name) {
809 for (col_name, col_type) in parsed_table.columns {
810 if let Some(existing_type) = existing.columns.get(&col_name) {
811 if existing_type != &col_type {
812 return Err(format!(
813 "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
814 table_name, col_name, existing_type, col_type
815 ));
816 }
817 } else {
818 existing.columns.insert(col_name.clone(), col_type);
819 changes += 1;
820 }
821 }
822 for (col_name, policy) in parsed_table.policies {
823 if existing.policies.insert(col_name, policy).is_none() {
824 changes += 1;
825 }
826 }
827 for fk in parsed_table.foreign_keys {
828 let duplicate = existing.foreign_keys.iter().any(|existing_fk| {
829 existing_fk.column == fk.column
830 && existing_fk.ref_table == fk.ref_table
831 && existing_fk.ref_column == fk.ref_column
832 });
833 if !duplicate {
834 existing.foreign_keys.push(fk);
835 changes += 1;
836 }
837 }
838 if parsed_table.rls_enabled && !existing.rls_enabled {
839 existing.rls_enabled = true;
840 changes += 1;
841 }
842 } else {
843 changes += 1 + parsed_table.columns.len();
844 self.tables.insert(table_name, parsed_table);
845 }
846 }
847
848 for view_name in parsed.views {
849 if self.views.insert(view_name) {
850 changes += 1;
851 }
852 }
853 for (resource_name, resource) in parsed.resources {
854 if self.resources.insert(resource_name, resource).is_none() {
855 changes += 1;
856 }
857 }
858
859 changes += self.parse_explicit_qail_apply_commands(qail)?;
860
861 Ok(changes)
862 }
863
864 fn parse_explicit_qail_apply_commands(&mut self, qail: &str) -> Result<usize, String> {
865 let mut changes = 0usize;
866
867 for (line_no, raw_line) in qail.lines().enumerate() {
868 let line = strip_schema_comments(raw_line);
869 if line.is_empty() || !line.starts_with("alter ") {
870 continue;
871 }
872
873 let mut alter_toks = line["alter ".len()..].split_whitespace();
878 let _alter_table = alter_toks.next();
879 let is_add_column = matches!(alter_toks.next(), Some("add"))
880 && !matches!(
881 alter_toks.next().map(|t| t.to_ascii_lowercase()).as_deref(),
882 Some("constraint")
883 | Some("primary")
884 | Some("foreign")
885 | Some("unique")
886 | Some("check")
887 | Some("index")
888 | None
889 );
890 if !is_add_column {
891 continue;
892 }
893
894 let (table, column_name, column_type) = parse_explicit_alter_add_column_line(line)
895 .map_err(|err| format!("Line {}: {}", line_no + 1, err))?;
896
897 if let Some(existing) = self.tables.get_mut(&table) {
898 if let Some(existing_type) = existing.columns.get(&column_name) {
899 if existing_type != &column_type {
900 return Err(format!(
901 "conflicting column type for '{}.{}': existing {:?}, migration {:?}",
902 table, column_name, existing_type, column_type
903 ));
904 }
905 } else {
906 existing.columns.insert(column_name, column_type);
907 changes += 1;
908 }
909 } else {
910 let mut columns = HashMap::new();
911 columns.insert(column_name, column_type);
912 self.tables.insert(
913 table.clone(),
914 TableSchema {
915 name: table,
916 columns,
917 policies: HashMap::new(),
918 foreign_keys: vec![],
919 rls_enabled: false,
920 },
921 );
922 changes += 2;
923 }
924 }
925
926 Ok(changes)
927 }
928
929 pub(crate) fn parse_sql_migration(&mut self, sql: &str) -> usize {
931 let mut changes = 0;
932
933 for statement in sql_migration_statements(sql) {
934 let line = statement.as_str();
935 let line_upper = line.to_uppercase();
936
937 if let Some((name, after_table_name)) = extract_create_table_name_with_tail(line) {
938 let table_existed = self.tables.contains_key(&name);
939 if !table_existed {
940 self.tables.insert(
941 name.clone(),
942 TableSchema {
943 name: name.clone(),
944 columns: HashMap::new(),
945 policies: HashMap::new(),
946 foreign_keys: vec![],
947 rls_enabled: false,
948 },
949 );
950 changes += 1;
951 }
952
953 let after_table_name = after_table_name.trim_start();
954 let has_column_block =
955 after_table_name.is_empty() || after_table_name.starts_with('(');
956 if has_column_block
961 && (!table_existed
962 || self.tables.get(&name).is_some_and(|t| t.columns.is_empty()))
963 {
964 for col in extract_inline_create_columns(line) {
965 if let Some(t) = self.tables.get_mut(&name)
966 && t.columns.insert(col, ColumnType::Text).is_none()
967 {
968 changes += 1;
969 }
970 }
971 }
972 continue;
973 }
974
975 for (table, col) in extract_alter_add_columns(line) {
977 if let Some(t) = self.tables.get_mut(&table) {
978 if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
979 changes += 1;
980 }
981 } else {
982 let mut cols = HashMap::new();
984 cols.insert(col, ColumnType::Text);
985 self.tables.insert(
986 table.clone(),
987 TableSchema {
988 name: table,
989 columns: cols,
990 policies: HashMap::new(),
991 foreign_keys: vec![],
992 rls_enabled: false,
993 },
994 );
995 changes += 1;
996 }
997 }
998
999 if line_upper.starts_with("DROP TABLE") {
1001 for table_name in extract_drop_table_names(line) {
1002 if self.tables.remove(&table_name).is_some() {
1003 changes += 1;
1004 }
1005 }
1006 }
1007
1008 for (table, col) in extract_alter_drop_columns(line) {
1010 if let Some(t) = self.tables.get_mut(&table)
1011 && t.columns.remove(&col).is_some()
1012 {
1013 changes += 1;
1014 }
1015 }
1016
1017 if line_upper.starts_with("ALTER TABLE")
1019 && let Some((table, old_col, new_col)) = extract_alter_rename_column(line)
1020 && let Some(t) = self.tables.get_mut(&table)
1021 {
1022 let old_type = t.columns.remove(&old_col);
1023 if old_type.is_some() {
1024 changes += 1;
1025 }
1026 if t.columns
1027 .insert(new_col, old_type.unwrap_or(ColumnType::Text))
1028 .is_none()
1029 {
1030 changes += 1;
1031 }
1032 }
1033
1034 if line_upper.starts_with("ALTER TABLE")
1036 && let Some((old_table, new_table)) = extract_alter_rename_table(line)
1037 && !self.tables.contains_key(&new_table)
1038 && let Some(mut table) = self.tables.remove(&old_table)
1039 {
1040 table.name = new_table.clone();
1041 self.tables.insert(new_table, table);
1042 changes += 1;
1043 }
1044 }
1045
1046 changes
1047 }
1048}
1049
1050fn sql_migration_statements(sql: &str) -> Vec<String> {
1051 let mut cleaned = String::new();
1052 let mut in_block_comment = false;
1053 let mut dollar_quote = None;
1054
1055 for raw_line in sql.lines() {
1056 let line = strip_sql_migration_comments(raw_line, &mut in_block_comment, &mut dollar_quote);
1057 if line.is_empty() {
1058 continue;
1059 }
1060 cleaned.push_str(&line);
1061 cleaned.push('\n');
1062 }
1063
1064 split_sql_statements(&cleaned)
1065}
1066
1067fn parse_build_column_type_prefix(
1068 parts: &[&str],
1069 enum_types: &HashMap<String, Vec<String>>,
1070) -> Option<(ColumnType, usize)> {
1071 let max_end = parts.len().min(5);
1072 for end in (2..=max_end).rev() {
1073 let type_str = parts[1..end].join(" ");
1074 if let Ok(column_type) = type_str.parse::<ColumnType>() {
1075 return Some((column_type, end));
1076 }
1077 if let Some(values) = enum_types.get(&type_str) {
1078 return Some((
1079 ColumnType::Enum {
1080 name: type_str,
1081 values: values.clone(),
1082 },
1083 end,
1084 ));
1085 }
1086 }
1087 None
1088}
1089
1090fn parse_build_enum_declaration<'a, I: Iterator<Item = &'a str>>(
1091 first_line: &str,
1092 lines: &mut std::iter::Peekable<I>,
1093) -> Result<(String, Vec<String>), String> {
1094 let rest = first_line
1095 .strip_prefix("enum ")
1096 .ok_or_else(|| "Expected 'enum' prefix".to_string())?
1097 .trim();
1098 let (name, body_start) = rest
1099 .split_once('{')
1100 .ok_or_else(|| "enum definition requires { values }".to_string())?;
1101 let name = name.trim();
1102 if name.is_empty() {
1103 return Err("enum name is missing before '{'".to_string());
1104 }
1105 if !is_build_table_ref(name) {
1106 return Err(format!("Invalid enum name '{}'", name));
1107 }
1108
1109 let mut body = body_start.to_string();
1110 while build_enum_body_before_closing_brace(&body)?.is_none() {
1111 let Some(next_line) = lines.next() else {
1112 return Err(format!("enum '{}' is missing closing '}}'", name));
1113 };
1114 let inner = strip_schema_comments(next_line);
1115 body.push(' ');
1116 body.push_str(inner);
1117 }
1118
1119 let body = build_enum_body_before_closing_brace(&body)?
1120 .ok_or_else(|| format!("enum '{}' is missing closing '}}'", name))?;
1121 let values = parse_build_enum_values(body)?;
1122 if values.is_empty() {
1123 return Err(format!("enum '{}' must have at least one value", name));
1124 }
1125
1126 Ok((name.to_string(), values))
1127}
1128
1129fn build_enum_body_before_closing_brace(raw: &str) -> Result<Option<&str>, String> {
1130 let mut quote: Option<char> = None;
1131 let mut chars = raw.char_indices().peekable();
1132
1133 while let Some((idx, ch)) = chars.next() {
1134 if let Some(q) = quote {
1135 if ch == q {
1136 if chars.peek().is_some_and(|(_, next)| *next == q) {
1137 chars.next();
1138 } else {
1139 quote = None;
1140 }
1141 }
1142 continue;
1143 }
1144
1145 match ch {
1146 '\'' | '"' => quote = Some(ch),
1147 '}' => {
1148 let rest = &raw[idx + ch.len_utf8()..];
1149 if !rest.trim().is_empty() {
1150 return Err("trailing content after enum block".to_string());
1151 }
1152 return Ok(Some(&raw[..idx]));
1153 }
1154 _ => {}
1155 }
1156 }
1157
1158 Ok(None)
1159}
1160
1161fn parse_build_enum_values(raw: &str) -> Result<Vec<String>, String> {
1162 let mut values = Vec::new();
1163 let mut quote: Option<char> = None;
1164 let mut start = 0;
1165 let mut chars = raw.char_indices().peekable();
1166
1167 while let Some((idx, ch)) = chars.next() {
1168 if let Some(q) = quote {
1169 if ch == q {
1170 if chars.peek().is_some_and(|(_, next)| *next == q) {
1171 chars.next();
1172 } else {
1173 quote = None;
1174 }
1175 }
1176 continue;
1177 }
1178
1179 match ch {
1180 '\'' | '"' => quote = Some(ch),
1181 ',' => {
1182 push_build_enum_value(&mut values, &raw[start..idx])?;
1183 start = idx + ch.len_utf8();
1184 }
1185 _ => {}
1186 }
1187 }
1188
1189 if quote.is_some() {
1190 return Err("unterminated quoted enum value".to_string());
1191 }
1192
1193 push_build_enum_value(&mut values, &raw[start..])?;
1194 let mut seen = HashSet::new();
1195 for value in &values {
1196 if !seen.insert(value) {
1197 return Err(format!("duplicate enum value '{}'", value));
1198 }
1199 }
1200
1201 Ok(values)
1202}
1203
1204fn push_build_enum_value(values: &mut Vec<String>, raw: &str) -> Result<(), String> {
1205 let was_quoted = raw
1206 .trim()
1207 .chars()
1208 .next()
1209 .is_some_and(|ch| matches!(ch, '\'' | '"'));
1210 let value = parse_build_enum_value(raw)?;
1211 if value.is_empty() && !was_quoted {
1212 return Err("enum value is empty".to_string());
1213 }
1214 values.push(value);
1215 Ok(())
1216}
1217
1218fn parse_build_enum_value(raw: &str) -> Result<String, String> {
1219 let trimmed = raw.trim();
1220 if trimmed.is_empty() {
1221 return Ok(String::new());
1222 }
1223
1224 if let Some(quote) = trimmed.chars().next().filter(|ch| matches!(ch, '"' | '\'')) {
1225 let mut value = String::new();
1226 let mut chars = trimmed.char_indices();
1227 chars.next();
1228 let mut chars = chars.peekable();
1229
1230 while let Some((idx, ch)) = chars.next() {
1231 if ch == quote {
1232 if chars.peek().is_some_and(|(_, next)| *next == quote) {
1233 value.push(quote);
1234 chars.next();
1235 continue;
1236 }
1237
1238 let after = idx + ch.len_utf8();
1239 if !trimmed[after..].trim().is_empty() {
1240 return Err(format!("invalid enum value token '{}'", trimmed));
1241 }
1242 return Ok(value);
1243 }
1244
1245 value.push(ch);
1246 }
1247
1248 return Err("unterminated quoted enum value".to_string());
1249 }
1250
1251 if trimmed
1252 .chars()
1253 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1254 {
1255 return Ok(trimmed.to_string());
1256 }
1257
1258 Err(format!("invalid enum value token '{}'", trimmed))
1259}
1260
1261fn parse_build_references_target(
1262 target: &str,
1263 col_name: &str,
1264 table_name: &str,
1265) -> Result<(String, String), String> {
1266 let target = target.trim();
1267 let (ref_table, ref_column) = target.split_once('(').ok_or_else(|| {
1268 format!(
1269 "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1270 target, col_name, table_name
1271 )
1272 })?;
1273 let ref_column = ref_column.strip_suffix(')').ok_or_else(|| {
1274 format!(
1275 "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1276 target, col_name, table_name
1277 )
1278 })?;
1279 let ref_table = ref_table.trim();
1280 let ref_column = ref_column.trim();
1281 if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1282 return Err(format!(
1283 "Invalid foreign key reference target '{}' for column '{}' in table '{}'",
1284 target, col_name, table_name
1285 ));
1286 }
1287
1288 Ok((ref_table.to_string(), ref_column.to_string()))
1289}
1290
1291fn parse_build_ref_spec(
1292 ref_spec: &str,
1293 col_name: &str,
1294 table_name: &str,
1295) -> Result<(String, String), String> {
1296 let ref_spec = ref_spec.trim_start_matches('>');
1297 let (ref_table, ref_column) = ref_spec.split_once('.').ok_or_else(|| {
1298 format!(
1299 "Invalid ref target '{}' for column '{}' in table '{}'",
1300 ref_spec, col_name, table_name
1301 )
1302 })?;
1303 let ref_table = ref_table.trim();
1304 let ref_column = ref_column.trim();
1305 if !is_build_table_ref(ref_table) || !is_build_identifier(ref_column) {
1306 return Err(format!(
1307 "Invalid ref target '{}' for column '{}' in table '{}'",
1308 ref_spec, col_name, table_name
1309 ));
1310 }
1311
1312 Ok((ref_table.to_string(), ref_column.to_string()))
1313}
1314
1315fn push_build_foreign_key(
1316 foreign_keys: &mut Vec<ForeignKey>,
1317 column: &str,
1318 ref_table: String,
1319 ref_column: String,
1320 table_name: &str,
1321) -> Result<(), String> {
1322 if foreign_keys
1323 .iter()
1324 .any(|fk| fk.column == column && fk.ref_table == ref_table && fk.ref_column == ref_column)
1325 {
1326 return Err(format!(
1327 "duplicate foreign key '{}.{} -> {}.{}'",
1328 table_name, column, ref_table, ref_column
1329 ));
1330 }
1331
1332 foreign_keys.push(ForeignKey {
1333 column: column.to_string(),
1334 ref_table,
1335 ref_column,
1336 });
1337 Ok(())
1338}
1339
1340fn is_build_table_ref(value: &str) -> bool {
1341 let mut parts = value.split('.');
1342 let Some(first) = parts.next() else {
1343 return false;
1344 };
1345 !first.is_empty() && is_build_identifier(first) && parts.all(is_build_identifier)
1346}
1347
1348fn is_build_identifier(value: &str) -> bool {
1349 !value.is_empty()
1350 && value
1351 .chars()
1352 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1353}
1354
1355fn is_build_fk_action(value: &str) -> bool {
1356 matches!(
1357 value,
1358 "cascade" | "set_null" | "set_default" | "restrict" | "no_action"
1359 )
1360}
1361
1362fn resource_block_content_before_closing(content: &str) -> Result<Option<String>, String> {
1363 let mut quote: Option<char> = None;
1364 let mut escaped = false;
1365
1366 for (idx, ch) in content.char_indices() {
1367 if escaped {
1368 escaped = false;
1369 continue;
1370 }
1371
1372 match quote {
1373 Some(q) => match ch {
1374 '\\' => escaped = true,
1375 c if c == q => quote = None,
1376 _ => {}
1377 },
1378 None => match ch {
1379 '"' | '\'' => quote = Some(ch),
1380 '}' => {
1381 let rest = &content[idx + ch.len_utf8()..];
1382 if !rest.trim().is_empty() {
1383 return Err("Trailing content after resource definition".to_string());
1384 }
1385 return Ok(Some(content[..idx].trim().to_string()));
1386 }
1387 _ => {}
1388 },
1389 }
1390 }
1391
1392 Ok(None)
1393}
1394
1395fn split_resource_tokens(content: &str) -> Result<Vec<String>, String> {
1396 let mut tokens = Vec::new();
1397 let mut current = String::new();
1398 let mut quote: Option<char> = None;
1399 let mut escaped = false;
1400
1401 for ch in content.chars() {
1402 if escaped {
1403 current.push(ch);
1404 escaped = false;
1405 continue;
1406 }
1407
1408 match quote {
1409 Some(q) => match ch {
1410 '\\' => escaped = true,
1411 c if c == q => quote = None,
1412 c => current.push(c),
1413 },
1414 None => match ch {
1415 '"' | '\'' => quote = Some(ch),
1416 c if c.is_whitespace() => {
1417 if !current.is_empty() {
1418 tokens.push(std::mem::take(&mut current));
1419 }
1420 }
1421 c => current.push(c),
1422 },
1423 }
1424 }
1425
1426 if escaped {
1427 current.push('\\');
1428 }
1429 if quote.is_some() {
1430 return Err("Unterminated quoted resource value".to_string());
1431 }
1432 if !current.is_empty() {
1433 tokens.push(current);
1434 }
1435
1436 Ok(tokens)
1437}
1438
1439fn parse_explicit_alter_add_column_line(
1440 line: &str,
1441) -> Result<(String, String, ColumnType), String> {
1442 let rest = line
1443 .strip_prefix("alter ")
1444 .ok_or_else(|| "expected 'alter <table> add <column:type[:constraints]>'".to_string())?
1445 .trim();
1446
1447 let mut parts = rest.splitn(2, char::is_whitespace);
1448 let table = parts
1449 .next()
1450 .map(str::trim)
1451 .filter(|table| !table.is_empty())
1452 .ok_or_else(|| "expected table name after 'alter'".to_string())?;
1453 if !is_build_table_ref(table) {
1454 return Err(format!("invalid alter table name '{}'", table));
1455 }
1456 let remainder = parts
1457 .next()
1458 .map(str::trim)
1459 .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?;
1460 let column_def = remainder
1461 .strip_prefix("add ")
1462 .ok_or_else(|| "expected 'add <column:type[:constraints]>' after table name".to_string())?
1463 .trim();
1464
1465 if column_def.is_empty() {
1466 return Err("expected column definition after 'add'".to_string());
1467 }
1468
1469 let (remaining, column_expr) = parse_column_definition(column_def)
1470 .map_err(|_| format!("invalid column definition '{}'", column_def))?;
1471 if !remaining.trim().is_empty() {
1472 return Err(format!(
1473 "unexpected trailing content after column definition: '{}'",
1474 remaining.trim()
1475 ));
1476 }
1477
1478 match column_expr {
1479 Expr::Def {
1480 name, data_type, ..
1481 } => {
1482 let column_type = data_type.parse::<ColumnType>().map_err(|_| {
1483 format!(
1484 "unknown column type '{}' for column '{}' in alter '{}'",
1485 data_type, name, table
1486 )
1487 })?;
1488 Ok((table.to_string(), name, column_type))
1489 }
1490 _ => Err("expected column definition after 'add'".to_string()),
1491 }
1492}
1493
1494fn extract_view_name(line: &str) -> Option<&str> {
1495 let rest = if let Some(r) = line.strip_prefix("view ") {
1496 r
1497 } else {
1498 line.strip_prefix("materialized view ")?
1499 };
1500
1501 let name = rest.split_whitespace().next().unwrap_or_default().trim();
1502 if name.is_empty() { None } else { Some(name) }
1503}
1504
1505fn extract_create_table_name_with_tail(line: &str) -> Option<(String, &str)> {
1506 let rest = extract_create_table_target_start(line)?;
1507 let rest = strip_sql_if_not_exists(rest).unwrap_or(rest);
1508
1509 extract_sql_table_ref_with_tail(rest)
1510}
1511
1512fn extract_create_table_target_start(line: &str) -> Option<&str> {
1513 let mut rest = strip_sql_keyword(line, "CREATE")?;
1514
1515 if let Some(after_unlogged) = strip_sql_keyword(rest, "UNLOGGED") {
1516 rest = after_unlogged;
1517 } else if strip_sql_keyword(rest, "TEMP")
1518 .or_else(|| strip_sql_keyword(rest, "TEMPORARY"))
1519 .is_some()
1520 {
1521 return None;
1522 }
1523
1524 strip_sql_keyword(rest, "TABLE")
1525}
1526
1527fn strip_sql_keyword<'a>(raw: &'a str, keyword: &str) -> Option<&'a str> {
1528 let rest = raw.trim_start();
1529 let tail = rest.get(keyword.len()..)?;
1530 if rest[..keyword.len()].eq_ignore_ascii_case(keyword)
1531 && (tail.is_empty() || tail.starts_with(char::is_whitespace))
1532 {
1533 Some(tail.trim_start())
1534 } else {
1535 None
1536 }
1537}
1538
1539fn strip_sql_if_exists(raw: &str) -> Option<&str> {
1540 let after_if = strip_sql_keyword(raw, "IF")?;
1541 strip_sql_keyword(after_if, "EXISTS")
1542}
1543
1544fn strip_sql_if_not_exists(raw: &str) -> Option<&str> {
1545 let after_if = strip_sql_keyword(raw, "IF")?;
1546 let after_not = strip_sql_keyword(after_if, "NOT")?;
1547 strip_sql_keyword(after_not, "EXISTS")
1548}
1549
1550fn extract_column_from_create(line: &str) -> Option<String> {
1552 let line = line.trim();
1553
1554 let line_upper = line.to_uppercase();
1559 let starts_with_keyword = |kw: &str| -> bool {
1560 line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
1561 };
1562
1563 if starts_with_keyword("CREATE")
1564 || starts_with_keyword("PRIMARY")
1565 || starts_with_keyword("FOREIGN")
1566 || starts_with_keyword("UNIQUE")
1567 || starts_with_keyword("CHECK")
1568 || starts_with_keyword("CONSTRAINT")
1569 || starts_with_keyword("EXCLUDE")
1570 || starts_with_keyword("LIKE")
1571 || line_upper.starts_with(")")
1572 || line_upper.starts_with("(")
1573 || line.is_empty()
1574 {
1575 return None;
1576 }
1577
1578 extract_sql_column_ref(line.trim_start_matches('(').trim())
1579}
1580
1581fn extract_inline_create_columns(line: &str) -> Vec<String> {
1582 let Some(open_idx) = line.find('(') else {
1583 return Vec::new();
1584 };
1585 let Some(close_idx) = find_matching_sql_paren(line, open_idx) else {
1586 return Vec::new();
1587 };
1588 let body = &line[open_idx + 1..close_idx];
1589 split_sql_top_level_csv(body)
1590 .into_iter()
1591 .filter_map(extract_column_from_create)
1592 .collect()
1593}
1594
1595fn find_matching_sql_paren(raw: &str, open_idx: usize) -> Option<usize> {
1596 let mut depth = 0usize;
1597 let mut in_single = false;
1598 let mut in_double = false;
1599 let mut dollar_quote: Option<String> = None;
1600 let mut i = open_idx;
1601
1602 while i < raw.len() {
1603 if let Some(delim) = dollar_quote.as_deref() {
1604 if raw[i..].starts_with(delim) {
1605 i += delim.len();
1606 dollar_quote = None;
1607 } else {
1608 i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1609 }
1610 continue;
1611 }
1612
1613 let ch = raw[i..].chars().next()?;
1614 match ch {
1615 '\'' if !in_double => {
1616 if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1617 i += 2;
1618 continue;
1619 }
1620 in_single = !in_single;
1621 }
1622 '"' if !in_single => {
1623 if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1624 i += 2;
1625 continue;
1626 }
1627 in_double = !in_double;
1628 }
1629 '$' if !in_single && !in_double => {
1630 if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1631 dollar_quote = Some(delim.to_string());
1632 i += delim.len();
1633 continue;
1634 }
1635 }
1636 '(' if !in_single && !in_double => depth += 1,
1637 ')' if !in_single && !in_double => {
1638 depth = depth.checked_sub(1)?;
1639 if depth == 0 {
1640 return Some(i);
1641 }
1642 }
1643 _ => {}
1644 }
1645 i += ch.len_utf8();
1646 }
1647
1648 None
1649}
1650
1651fn split_sql_top_level_csv(raw: &str) -> Vec<&str> {
1652 let mut pieces = Vec::new();
1653 let mut start = 0usize;
1654 let mut depth = 0usize;
1655 let mut in_single = false;
1656 let mut in_double = false;
1657 let mut dollar_quote: Option<String> = None;
1658 let mut i = 0usize;
1659
1660 while i < raw.len() {
1661 if let Some(delim) = dollar_quote.as_deref() {
1662 if raw[i..].starts_with(delim) {
1663 i += delim.len();
1664 dollar_quote = None;
1665 } else {
1666 i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1667 }
1668 continue;
1669 }
1670
1671 let Some(ch) = raw[i..].chars().next() else {
1672 break;
1673 };
1674 match ch {
1675 '\'' if !in_double => {
1676 if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1677 i += 2;
1678 continue;
1679 }
1680 in_single = !in_single;
1681 }
1682 '"' if !in_single => {
1683 if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1684 i += 2;
1685 continue;
1686 }
1687 in_double = !in_double;
1688 }
1689 '$' if !in_single && !in_double => {
1690 if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1691 dollar_quote = Some(delim.to_string());
1692 i += delim.len();
1693 continue;
1694 }
1695 }
1696 '(' if !in_single && !in_double => depth += 1,
1697 ')' if !in_single && !in_double => depth = depth.saturating_sub(1),
1698 ',' if depth == 0 => {
1699 pieces.push(raw[start..i].trim());
1700 start = i + ch.len_utf8();
1701 }
1702 _ => {}
1703 }
1704 i += ch.len_utf8();
1705 }
1706
1707 pieces.push(raw[start..].trim());
1708 pieces
1709}
1710
1711fn split_sql_statements(raw: &str) -> Vec<String> {
1712 let mut statements = Vec::new();
1713 let mut start = 0usize;
1714 let mut in_single = false;
1715 let mut in_double = false;
1716 let mut dollar_quote: Option<String> = None;
1717 let mut i = 0usize;
1718
1719 while i < raw.len() {
1720 if let Some(delim) = dollar_quote.as_deref() {
1721 if raw[i..].starts_with(delim) {
1722 i += delim.len();
1723 dollar_quote = None;
1724 } else {
1725 i += raw[i..].chars().next().map(char::len_utf8).unwrap_or(1);
1726 }
1727 continue;
1728 }
1729
1730 let Some(ch) = raw[i..].chars().next() else {
1731 break;
1732 };
1733 match ch {
1734 '\'' if !in_double => {
1735 if in_single && raw[i + ch.len_utf8()..].starts_with('\'') {
1736 i += 2;
1737 continue;
1738 }
1739 in_single = !in_single;
1740 }
1741 '"' if !in_single => {
1742 if in_double && raw[i + ch.len_utf8()..].starts_with('"') {
1743 i += 2;
1744 continue;
1745 }
1746 in_double = !in_double;
1747 }
1748 '$' if !in_single && !in_double => {
1749 if let Some(delim) = sql_dollar_quote_delimiter_at(raw, i) {
1750 dollar_quote = Some(delim.to_string());
1751 i += delim.len();
1752 continue;
1753 }
1754 }
1755 ';' if !in_single && !in_double => {
1756 let statement = raw[start..i].trim();
1757 if !statement.is_empty() {
1758 statements.push(statement.to_string());
1759 }
1760 start = i + ch.len_utf8();
1761 }
1762 _ => {}
1763 }
1764 i += ch.len_utf8();
1765 }
1766
1767 let tail = raw[start..].trim();
1768 if !tail.is_empty() {
1769 statements.push(tail.to_string());
1770 }
1771
1772 statements
1773}
1774
1775fn extract_alter_add_columns(line: &str) -> Vec<(String, String)> {
1777 let line_upper = line.to_uppercase();
1778 if !line_upper.starts_with("ALTER TABLE") {
1779 return Vec::new();
1780 }
1781 let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1782 return Vec::new();
1783 };
1784
1785 split_sql_top_level_csv(actions_part)
1786 .into_iter()
1787 .filter_map(|action| {
1788 extract_alter_add_column_action(action).map(|col| (table.clone(), col))
1789 })
1790 .collect()
1791}
1792
1793fn extract_alter_add_column_action(action: &str) -> Option<String> {
1794 let mut col_part = strip_sql_keyword(action, "ADD")?;
1795 col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1796 col_part = strip_sql_if_not_exists(col_part).unwrap_or(col_part);
1797
1798 let col_upper = col_part.trim_start().to_uppercase();
1799 if [
1800 "CONSTRAINT",
1801 "PRIMARY",
1802 "UNIQUE",
1803 "CHECK",
1804 "FOREIGN",
1805 "EXCLUDE",
1806 ]
1807 .iter()
1808 .any(|keyword| {
1809 col_upper.starts_with(keyword) && col_upper[keyword.len()..].starts_with([' ', '('])
1810 }) {
1811 return None;
1812 }
1813
1814 extract_sql_column_ref(col_part.trim())
1815}
1816
1817fn extract_drop_table_names(line: &str) -> Vec<String> {
1819 let line_upper = line.to_uppercase();
1820 let Some(rest) = line_upper.strip_prefix("DROP TABLE") else {
1821 return Vec::new();
1822 };
1823 let rest = rest.trim_start();
1824 let rest = if rest.starts_with("IF EXISTS") {
1825 match rest.strip_prefix("IF EXISTS") {
1826 Some(rest) => rest.trim_start(),
1827 None => return Vec::new(),
1828 }
1829 } else {
1830 rest
1831 };
1832
1833 split_sql_top_level_csv(&line[line.len() - rest.len()..])
1834 .into_iter()
1835 .filter_map(extract_sql_table_ref)
1836 .collect()
1837}
1838
1839fn extract_alter_drop_columns(line: &str) -> Vec<(String, String)> {
1841 let line_upper = line.to_uppercase();
1842 if !line_upper.starts_with("ALTER TABLE") {
1843 return Vec::new();
1844 }
1845 let Some((table, actions_part)) = extract_alter_table_ref_with_tail(&line[11..]) else {
1846 return Vec::new();
1847 };
1848
1849 split_sql_top_level_csv(actions_part)
1850 .into_iter()
1851 .filter_map(|action| {
1852 extract_alter_drop_column_action(action).map(|col| (table.clone(), col))
1853 })
1854 .collect()
1855}
1856
1857fn extract_alter_drop_column_action(action: &str) -> Option<String> {
1858 let mut col_part = strip_sql_keyword(action, "DROP")?;
1859 col_part = strip_sql_keyword(col_part, "COLUMN").unwrap_or(col_part);
1860 col_part = strip_sql_if_exists(col_part).unwrap_or(col_part);
1861
1862 let col_upper = col_part.trim_start().to_uppercase();
1863 if ["CONSTRAINT", "INDEX"].iter().any(|keyword| {
1864 col_upper.starts_with(keyword)
1865 && col_upper[keyword.len()..].starts_with(char::is_whitespace)
1866 }) {
1867 return None;
1868 }
1869
1870 extract_sql_column_ref(col_part.trim())
1871}
1872
1873fn extract_alter_rename_column(line: &str) -> Option<(String, String, String)> {
1874 let line_upper = line.to_uppercase();
1875 if !line_upper.starts_with("ALTER TABLE") {
1876 return None;
1877 }
1878 let (table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1879 let actions_upper = actions_part.to_uppercase();
1880 let (rename_pos, rename_len) = if let Some(pos) = actions_upper.find("RENAME COLUMN") {
1881 (pos, "RENAME COLUMN".len())
1882 } else {
1883 (actions_upper.find("RENAME ")?, "RENAME".len())
1884 };
1885 let to_pos = actions_upper[rename_pos + rename_len..].find(" TO ")? + rename_pos + rename_len;
1886
1887 let old_part = &actions_part[rename_pos + rename_len..to_pos];
1888 let new_part = &actions_part[to_pos + 4..];
1889 let old_col = extract_sql_column_ref(old_part.trim())?;
1890 let new_col = extract_sql_column_ref(new_part.trim())?;
1891
1892 Some((table, old_col, new_col))
1893}
1894
1895fn extract_alter_rename_table(line: &str) -> Option<(String, String)> {
1896 let line_upper = line.to_uppercase();
1897 if !line_upper.starts_with("ALTER TABLE") {
1898 return None;
1899 }
1900 let (old_table, actions_part) = extract_alter_table_ref_with_tail(&line[11..])?;
1901 let actions_upper = actions_part.to_uppercase();
1902 let rename_pos = actions_upper.find("RENAME TO ")?;
1903
1904 let new_part = &actions_part[rename_pos + "RENAME TO ".len()..];
1905 let new_ref = extract_sql_table_ref(new_part.trim())?;
1906 let new_table = if new_ref.contains('.') {
1907 new_ref
1908 } else if let Some((schema, _)) = old_table.rsplit_once('.') {
1909 format!("{schema}.{new_ref}")
1910 } else {
1911 new_ref
1912 };
1913
1914 Some((old_table, new_table))
1915}
1916
1917fn extract_sql_table_ref(raw: &str) -> Option<String> {
1918 extract_sql_table_ref_with_tail(raw).map(|(name, _)| name)
1919}
1920
1921fn extract_sql_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1922 let mut rest = raw.trim_start();
1923 let mut parts = Vec::new();
1924
1925 loop {
1926 let (part, tail, _) = parse_sql_identifier_segment(rest)?;
1927 parts.push(part.to_ascii_lowercase());
1928 rest = tail.trim_start();
1929 if let Some(tail) = rest.strip_prefix('.') {
1930 rest = tail.trim_start();
1931 } else {
1932 break;
1933 }
1934 }
1935
1936 let name = parts.join(".");
1937 is_build_table_ref(&name).then_some((name, rest))
1938}
1939
1940fn extract_sql_column_ref(raw: &str) -> Option<String> {
1941 let (name, rest, quoted) = parse_sql_identifier_segment(raw)?;
1942 if rest.trim_start().starts_with('.') {
1943 return None;
1944 }
1945 let name = name.to_ascii_lowercase();
1946 if name.is_empty() || !is_build_identifier(&name) || (!quoted && name == "if") {
1947 None
1948 } else {
1949 Some(name)
1950 }
1951}
1952
1953fn parse_sql_identifier_segment(raw: &str) -> Option<(String, &str, bool)> {
1954 let rest = raw.trim_start();
1955 if let Some(quoted) = rest.strip_prefix('"') {
1956 let mut out = String::new();
1957 let mut chars = quoted.char_indices().peekable();
1958 while let Some((idx, ch)) = chars.next() {
1959 if ch == '"' {
1960 if chars.peek().is_some_and(|(_, next)| *next == '"') {
1961 out.push('"');
1962 chars.next();
1963 continue;
1964 }
1965 let consumed = 1 + idx + ch.len_utf8();
1966 return Some((out, &rest[consumed..], true));
1967 }
1968 out.push(ch);
1969 }
1970 return None;
1971 }
1972
1973 let name: String = rest
1974 .chars()
1975 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1976 .collect();
1977 if name.is_empty() {
1978 return None;
1979 }
1980 let tail = &rest[name.len()..];
1981 Some((name, tail, false))
1982}
1983
1984fn extract_alter_table_ref_with_tail(raw: &str) -> Option<(String, &str)> {
1985 let mut rest = raw.trim_start();
1986 let upper = rest.to_uppercase();
1987 if upper.starts_with("IF EXISTS")
1988 && rest
1989 .get("IF EXISTS".len()..)
1990 .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1991 {
1992 rest = rest.get("IF EXISTS".len()..)?.trim_start();
1993 }
1994 let upper = rest.to_uppercase();
1995 if upper.starts_with("ONLY")
1996 && rest
1997 .get("ONLY".len()..)
1998 .is_some_and(|tail| tail.starts_with(char::is_whitespace))
1999 {
2000 rest = rest.get("ONLY".len()..)?.trim_start();
2001 }
2002 let (table, tail) = extract_sql_table_ref_with_tail(rest)?;
2003 Some((table, tail.trim_start()))
2004}
2005
2006impl TableSchema {
2007 pub fn has_column(&self, name: &str) -> bool {
2009 self.columns.contains_key(name)
2010 }
2011
2012 pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
2014 self.columns.get(name)
2015 }
2016
2017 pub fn primary_key_column(&self) -> &str {
2023 if self.columns.contains_key("id") {
2024 "id"
2025 } else {
2026 let singular = self.name.trim_end_matches('s');
2029 let conventional = format!("{}_id", singular);
2030 if self.columns.contains_key(&conventional) {
2031 return "id"; }
2035 "id" }
2037 }
2038}
2039
2040#[cfg(test)]
2041mod comment_tests {
2042 use super::{ColumnType, Schema, strip_schema_comments, strip_sql_line_comments};
2043
2044 #[test]
2045 fn schema_comment_stripping_ignores_markers_inside_quotes() {
2046 assert_eq!(
2047 strip_schema_comments(r#"status TEXT default 'draft--internal#tag' # comment"#),
2048 r#"status TEXT default 'draft--internal#tag'"#
2049 );
2050 assert_eq!(
2051 strip_schema_comments(r#"status TEXT default "draft--internal#tag" -- comment"#),
2052 r#"status TEXT default "draft--internal#tag""#
2053 );
2054 }
2055
2056 #[test]
2057 fn sql_comment_stripping_ignores_double_dash_inside_strings() {
2058 assert_eq!(
2059 strip_sql_line_comments("CREATE TABLE logs (message text DEFAULT 'a--b'); -- comment"),
2060 "CREATE TABLE logs (message text DEFAULT 'a--b');"
2061 );
2062 assert_eq!(
2063 strip_sql_line_comments("CREATE TABLE tags (name text DEFAULT '#not-comment');"),
2064 "CREATE TABLE tags (name text DEFAULT '#not-comment');"
2065 );
2066 }
2067
2068 #[test]
2069 fn sql_migration_paren_depth_ignores_string_literals() {
2070 let mut schema = Schema::default();
2071 schema.parse_sql_migration(
2072 r#"
2073CREATE TABLE logs (
2074 message text DEFAULT ')',
2075 tag text DEFAULT '(',
2076 level text
2077);
2078"#,
2079 );
2080
2081 let logs = schema.table("logs").expect("logs table should parse");
2082 assert!(logs.has_column("message"));
2083 assert!(logs.has_column("tag"));
2084 assert!(logs.has_column("level"));
2085 }
2086
2087 #[test]
2088 fn schema_parse_accepts_pulled_rls_directives() {
2089 let schema = Schema::parse(
2090 r#"
2091table agents {
2092 id UUID
2093 tenant_id UUID
2094 enable_rls
2095 force_rls
2096}
2097"#,
2098 )
2099 .expect("pulled schema RLS directives should parse");
2100
2101 let agents = schema.table("agents").expect("agents table should parse");
2102 assert!(agents.has_column("id"));
2103 assert!(agents.rls_enabled);
2104 assert!(!agents.has_column("enable_rls"));
2105 assert!(!agents.has_column("force_rls"));
2106 }
2107
2108 #[test]
2109 fn schema_parse_accepts_multi_word_column_types() {
2110 let schema = Schema::parse(
2111 r#"
2112table car_fullday_reseller_pricing {
2113 percentage_markup DOUBLE PRECISION
2114 starts_at TIMESTAMP WITH TIME ZONE
2115}
2116"#,
2117 )
2118 .expect("pulled schema multi-word types should parse");
2119
2120 let pricing = schema
2121 .table("car_fullday_reseller_pricing")
2122 .expect("pricing table should parse");
2123 assert_eq!(
2124 pricing.column_type("percentage_markup"),
2125 Some(&ColumnType::Float)
2126 );
2127 assert_eq!(
2128 pricing.column_type("starts_at"),
2129 Some(&ColumnType::Timestamptz)
2130 );
2131 }
2132
2133 #[test]
2134 fn sql_migration_ignores_multiline_block_comments() {
2135 let mut schema = Schema::default();
2136 schema.parse_sql_migration(
2137 r#"
2138CREATE TABLE users (
2139 id uuid
2140);
2141
2142/*
2143ALTER TABLE users ADD COLUMN hidden text;
2144CREATE TABLE hidden_table (
2145 id uuid
2146);
2147*/
2148"#,
2149 );
2150
2151 let users = schema.table("users").expect("users table should parse");
2152 assert!(users.has_column("id"));
2153 assert!(!users.has_column("hidden"));
2154 assert!(!schema.has_table("hidden_table"));
2155 }
2156
2157 #[test]
2158 fn sql_migration_preserves_schema_qualified_table_names() {
2159 let mut schema = Schema::default();
2160 schema.parse_sql_migration(
2161 r#"
2162CREATE TABLE app.users (
2163 id uuid
2164);
2165
2166ALTER TABLE app.users ADD COLUMN email text;
2167"#,
2168 );
2169
2170 assert!(!schema.has_table("app"));
2171 let users = schema
2172 .table("app.users")
2173 .expect("schema-qualified table should parse");
2174 assert!(users.has_column("id"));
2175 assert!(users.has_column("email"));
2176 }
2177
2178 #[test]
2179 fn sql_migration_extracts_inline_create_table_columns() {
2180 let mut schema = Schema::default();
2181 schema.parse_sql_migration(
2182 "CREATE TABLE users (id uuid, email text DEFAULT 'a,b', CHECK (length(email) > 3));",
2183 );
2184
2185 let users = schema.table("users").expect("users table should parse");
2186 assert!(users.has_column("id"));
2187 assert!(users.has_column("email"));
2188 assert!(!users.has_column("check"));
2189 }
2190
2191 #[test]
2192 fn sql_migration_drops_multiple_tables() {
2193 let mut schema = Schema::default();
2194 schema.parse_sql_migration(
2195 r#"
2196CREATE TABLE app.users (id uuid);
2197CREATE TABLE app.posts (id uuid);
2198DROP TABLE IF EXISTS app.users, app.posts CASCADE;
2199"#,
2200 );
2201
2202 assert!(!schema.has_table("app.users"));
2203 assert!(!schema.has_table("app.posts"));
2204 }
2205
2206 #[test]
2207 fn sql_migration_ignores_create_table_non_column_clauses() {
2208 let mut schema = Schema::default();
2209 schema.parse_sql_migration(
2210 r#"
2211CREATE TABLE bookings (
2212 id uuid,
2213 EXCLUDE USING gist (room WITH =),
2214 LIKE booking_template INCLUDING ALL
2215);
2216"#,
2217 );
2218
2219 let bookings = schema
2220 .table("bookings")
2221 .expect("bookings table should parse");
2222 assert!(bookings.has_column("id"));
2223 assert!(!bookings.has_column("exclude"));
2224 assert!(!bookings.has_column("like"));
2225 }
2226
2227 #[test]
2228 fn sql_migration_ignores_alter_add_constraints() {
2229 let mut schema = Schema::default();
2230 schema.parse_sql_migration(
2231 r#"
2232CREATE TABLE users (id uuid, email text);
2233ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
2234ALTER TABLE users ADD PRIMARY KEY (id);
2235"#,
2236 );
2237
2238 let users = schema.table("users").expect("users table should parse");
2239 assert!(users.has_column("id"));
2240 assert!(users.has_column("email"));
2241 assert!(!users.has_column("constraint"));
2242 assert!(!users.has_column("primary"));
2243 }
2244
2245 #[test]
2246 fn sql_migration_handles_alter_table_modifiers() {
2247 let mut schema = Schema::default();
2248 schema.parse_sql_migration(
2249 r#"
2250CREATE TABLE users (id uuid);
2251ALTER TABLE ONLY users ADD COLUMN email text;
2252ALTER TABLE IF EXISTS users DROP COLUMN id;
2253"#,
2254 );
2255
2256 assert!(!schema.has_table("only"));
2257 assert!(!schema.has_table("if"));
2258 let users = schema.table("users").expect("users table should parse");
2259 assert!(!users.has_column("id"));
2260 assert!(users.has_column("email"));
2261 }
2262
2263 #[test]
2264 fn sql_migration_handles_drop_column_if_exists() {
2265 let mut schema = Schema::default();
2266 schema.parse_sql_migration(
2267 r#"
2268CREATE TABLE users (id uuid, old_email text, old_name text);
2269ALTER TABLE users DROP COLUMN IF EXISTS old_email;
2270ALTER TABLE users DROP IF EXISTS old_name;
2271"#,
2272 );
2273
2274 let users = schema.table("users").expect("users table should parse");
2275 assert!(users.has_column("id"));
2276 assert!(!users.has_column("old_email"));
2277 assert!(!users.has_column("old_name"));
2278 assert!(!users.has_column("if"));
2279 }
2280
2281 #[test]
2282 fn sql_migration_handles_quoted_table_and_column_identifiers() {
2283 let mut schema = Schema::default();
2284 schema.parse_sql_migration(
2285 r#"
2286CREATE TABLE "app"."order" ("id" uuid, "select" text);
2287ALTER TABLE "app"."order" ADD COLUMN "from" text;
2288ALTER TABLE "app"."order" DROP COLUMN "select";
2289"#,
2290 );
2291
2292 let orders = schema
2293 .table("app.order")
2294 .expect("quoted schema-qualified table should parse");
2295 assert!(orders.has_column("id"));
2296 assert!(orders.has_column("from"));
2297 assert!(!orders.has_column("select"));
2298 }
2299
2300 #[test]
2301 fn sql_migration_ignores_dollar_quoted_default_syntax() {
2302 let mut schema = Schema::default();
2303 schema.parse_sql_migration(
2304 r#"
2305CREATE TABLE logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2306"#,
2307 );
2308
2309 let logs = schema.table("logs").expect("logs table should parse");
2310 assert!(logs.has_column("id"));
2311 assert!(logs.has_column("body"));
2312 assert!(logs.has_column("tag"));
2313 assert!(!logs.has_column("b"));
2314 assert!(!logs.has_column("not"));
2315 }
2316
2317 #[test]
2318 fn sql_migration_ignores_multiline_dollar_quoted_bodies() {
2319 let mut schema = Schema::default();
2320 schema.parse_sql_migration(
2321 r#"
2322CREATE TABLE users (id uuid);
2323CREATE FUNCTION rebuild_hidden() RETURNS void AS $$
2324BEGIN
2325 CREATE TABLE hidden_from_function (id uuid);
2326END;
2327$$ LANGUAGE plpgsql;
2328"#,
2329 );
2330
2331 assert!(schema.has_table("users"));
2332 assert!(!schema.has_table("hidden_from_function"));
2333 }
2334
2335 #[test]
2336 fn sql_migration_handles_unlogged_create_tables() {
2337 let mut schema = Schema::default();
2338 schema.parse_sql_migration(
2339 r#"
2340CREATE UNLOGGED TABLE IF NOT EXISTS jobs (id uuid, status text);
2341CREATE TEMP TABLE scratch_jobs (id uuid);
2342"#,
2343 );
2344
2345 let jobs = schema.table("jobs").expect("unlogged table should parse");
2346 assert!(jobs.has_column("id"));
2347 assert!(jobs.has_column("status"));
2348 assert!(!schema.has_table("scratch_jobs"));
2349 }
2350
2351 #[test]
2352 fn sql_migration_tracks_column_renames() {
2353 let mut schema = Schema::default();
2354 schema.parse_sql_migration(
2355 r#"
2356CREATE TABLE users (id uuid, old_email text);
2357ALTER TABLE users RENAME COLUMN old_email TO email;
2358"#,
2359 );
2360
2361 let users = schema.table("users").expect("users table should parse");
2362 assert!(users.has_column("id"));
2363 assert!(users.has_column("email"));
2364 assert!(!users.has_column("old_email"));
2365 }
2366
2367 #[test]
2368 fn sql_migration_tracks_table_renames() {
2369 let mut schema = Schema::default();
2370 schema.parse_sql_migration(
2371 r#"
2372CREATE TABLE app.users (id uuid, email text);
2373ALTER TABLE app.users RENAME TO customers;
2374"#,
2375 );
2376
2377 assert!(!schema.has_table("app.users"));
2378 let customers = schema
2379 .table("app.customers")
2380 .expect("schema-qualified table rename should parse");
2381 assert!(customers.has_column("id"));
2382 assert!(customers.has_column("email"));
2383 }
2384
2385 #[test]
2386 fn sql_migration_handles_add_if_not_exists_without_column_keyword() {
2387 let mut schema = Schema::default();
2388 schema.parse_sql_migration(
2389 r#"
2390CREATE TABLE users (id uuid);
2391ALTER TABLE users ADD IF NOT EXISTS email text;
2392"#,
2393 );
2394
2395 let users = schema.table("users").expect("users table should parse");
2396 assert!(users.has_column("id"));
2397 assert!(users.has_column("email"));
2398 assert!(!users.has_column("if"));
2399 }
2400
2401 #[test]
2402 fn sql_migration_tracks_column_renames_without_column_keyword() {
2403 let mut schema = Schema::default();
2404 schema.parse_sql_migration(
2405 r#"
2406CREATE TABLE users (id uuid, old_email text);
2407ALTER TABLE users RENAME old_email TO email;
2408"#,
2409 );
2410
2411 let users = schema.table("users").expect("users table should parse");
2412 assert!(users.has_column("email"));
2413 assert!(!users.has_column("old_email"));
2414 }
2415
2416 #[test]
2417 fn sql_migration_does_not_treat_create_table_as_select_as_column_block() {
2418 let mut schema = Schema::default();
2419 schema.parse_sql_migration(
2420 r#"
2421CREATE TABLE reports AS SELECT id FROM users;
2422ALTER TABLE reports ADD COLUMN status text;
2423"#,
2424 );
2425
2426 let reports = schema.table("reports").expect("reports table should parse");
2427 assert!(reports.has_column("status"));
2428 assert!(!reports.has_column("alter"));
2429 }
2430
2431 #[test]
2432 fn sql_migration_handles_multiple_alter_add_actions() {
2433 let mut schema = Schema::default();
2434 schema.parse_sql_migration(
2435 r#"
2436CREATE TABLE users (id uuid);
2437ALTER TABLE users ADD COLUMN email text, ADD IF NOT EXISTS name text;
2438"#,
2439 );
2440
2441 let users = schema.table("users").expect("users table should parse");
2442 assert!(users.has_column("email"));
2443 assert!(users.has_column("name"));
2444 }
2445
2446 #[test]
2447 fn sql_migration_handles_multiple_alter_drop_actions() {
2448 let mut schema = Schema::default();
2449 schema.parse_sql_migration(
2450 r#"
2451CREATE TABLE users (id uuid, old_email text, old_name text);
2452ALTER TABLE users DROP COLUMN old_email, DROP IF EXISTS old_name;
2453"#,
2454 );
2455
2456 let users = schema.table("users").expect("users table should parse");
2457 assert!(users.has_column("id"));
2458 assert!(!users.has_column("old_email"));
2459 assert!(!users.has_column("old_name"));
2460 }
2461
2462 #[test]
2463 fn sql_migration_handles_multiline_mixed_alter_actions() {
2464 let mut schema = Schema::default();
2465 schema.parse_sql_migration(
2466 r#"
2467CREATE TABLE users (id uuid, old_email text, old_name text);
2468ALTER TABLE users
2469 ADD COLUMN email text,
2470 DROP COLUMN old_email,
2471 RENAME COLUMN old_name TO legacy_name;
2472"#,
2473 );
2474
2475 let users = schema.table("users").expect("users table should parse");
2476 assert!(users.has_column("id"));
2477 assert!(users.has_column("email"));
2478 assert!(users.has_column("legacy_name"));
2479 assert!(!users.has_column("old_email"));
2480 assert!(!users.has_column("old_name"));
2481 }
2482
2483 #[test]
2484 fn sql_migration_handles_drop_then_recreate_order() {
2485 let mut schema = Schema::default();
2486 schema.parse_sql_migration(
2487 r#"
2488CREATE TABLE users (stale text);
2489DROP TABLE users;
2490CREATE TABLE users (id uuid, email text);
2491"#,
2492 );
2493
2494 let users = schema
2495 .table("users")
2496 .expect("recreated table should remain in schema");
2497 assert!(users.has_column("id"));
2498 assert!(users.has_column("email"));
2499 assert!(!users.has_column("stale"));
2500 }
2501
2502 #[test]
2503 fn sql_migration_allows_alter_add_columns_with_constraint_prefixes() {
2504 let mut schema = Schema::default();
2505 schema.parse_sql_migration(
2506 r#"
2507CREATE TABLE users (id uuid);
2508ALTER TABLE users ADD COLUMN primary_contact text, ADD check_status text;
2509"#,
2510 );
2511
2512 let users = schema.table("users").expect("users table should parse");
2513 assert!(users.has_column("primary_contact"));
2514 assert!(users.has_column("check_status"));
2515 }
2516
2517 #[test]
2518 fn sql_migration_handles_create_table_paren_on_next_line() {
2519 let mut schema = Schema::default();
2520 schema.parse_sql_migration(
2521 r#"
2522CREATE TABLE users
2523(
2524 id uuid,
2525 email text
2526);
2527"#,
2528 );
2529
2530 let users = schema.table("users").expect("users table should parse");
2531 assert!(users.has_column("id"));
2532 assert!(users.has_column("email"));
2533 }
2534
2535 #[test]
2536 fn sql_migration_does_not_treat_alter_column_drop_as_column_drop() {
2537 let mut schema = Schema::default();
2538 schema.parse_sql_migration(
2539 r#"
2540CREATE TABLE users (id uuid, email text, not text);
2541ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
2542"#,
2543 );
2544
2545 let users = schema.table("users").expect("users table should parse");
2546 assert!(users.has_column("email"));
2547 assert!(users.has_column("not"));
2548 }
2549
2550 #[test]
2551 fn sql_migration_chaos_mixed_postgres_syntax() {
2552 let mut schema = Schema::default();
2553 schema.parse_sql_migration(
2554 r#"
2555CREATE SCHEMA app;
2556CREATE UNLOGGED TABLE IF NOT EXISTS "app"."users"
2557(
2558 id uuid,
2559 old_email text,
2560 old_name text,
2561 "select" text,
2562 "not" text
2563);
2564CREATE TEMP TABLE scratch_jobs (id uuid);
2565ALTER TABLE ONLY "app"."users" ADD COLUMN primary_contact text, ADD check_status text;
2566ALTER TABLE "app"."users" ADD IF NOT EXISTS guarded text;
2567ALTER TABLE "app"."users" DROP COLUMN "select", DROP IF EXISTS guarded, DROP COLUMN IF EXISTS old_name;
2568ALTER TABLE "app"."users" RENAME old_email TO email;
2569ALTER TABLE "app"."users" ALTER COLUMN email DROP NOT NULL;
2570ALTER TABLE "app"."users" RENAME TO customers;
2571
2572CREATE TABLE app.logs (id uuid, body text DEFAULT $$a,b)--not-comment$$, tag text);
2573CREATE FUNCTION app.rebuild_hidden() RETURNS void AS $$
2574BEGIN
2575 CREATE TABLE hidden_from_function (id uuid);
2576END;
2577$$ LANGUAGE plpgsql;
2578CREATE TABLE app.reports AS SELECT id FROM app.customers;
2579ALTER TABLE app.reports ADD COLUMN status text;
2580"#,
2581 );
2582
2583 assert!(!schema.has_table("scratch_jobs"));
2584 assert!(!schema.has_table("app.users"));
2585 assert!(!schema.has_table("hidden_from_function"));
2586
2587 let customers = schema
2588 .table("app.customers")
2589 .expect("renamed schema-qualified table should parse");
2590 assert!(customers.has_column("id"));
2591 assert!(customers.has_column("email"));
2592 assert!(customers.has_column("not"));
2593 assert!(customers.has_column("primary_contact"));
2594 assert!(customers.has_column("check_status"));
2595 assert!(!customers.has_column("old_email"));
2596 assert!(!customers.has_column("old_name"));
2597 assert!(!customers.has_column("select"));
2598 assert!(!customers.has_column("guarded"));
2599
2600 let logs = schema.table("app.logs").expect("logs table should parse");
2601 assert!(logs.has_column("id"));
2602 assert!(logs.has_column("body"));
2603 assert!(logs.has_column("tag"));
2604 assert!(!logs.has_column("b"));
2605
2606 let reports = schema
2607 .table("app.reports")
2608 .expect("ctas table should parse");
2609 assert!(reports.has_column("status"));
2610 assert!(!reports.has_column("alter"));
2611 }
2612}