1use crate::migrate::types::ColumnType;
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7#[derive(Debug, Clone)]
9pub struct ForeignKey {
10 pub column: String,
12 pub ref_table: String,
14 pub ref_column: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct TableSchema {
21 pub name: String,
23 pub columns: HashMap<String, ColumnType>,
25 pub policies: HashMap<String, String>,
27 pub foreign_keys: Vec<ForeignKey>,
29 pub rls_enabled: bool,
32}
33
34#[derive(Debug, Default)]
36pub struct Schema {
37 pub tables: HashMap<String, TableSchema>,
39 pub views: HashSet<String>,
41 pub resources: HashMap<String, ResourceSchema>,
43}
44
45#[derive(Debug, Clone)]
47pub struct ResourceSchema {
48 pub name: String,
50 pub kind: String,
52 pub provider: Option<String>,
54 pub properties: HashMap<String, String>,
56}
57
58fn strip_schema_comments(line: &str) -> &str {
59 let line = line.split_once("--").map_or(line, |(left, _)| left);
60 line.split_once('#').map_or(line, |(left, _)| left).trim()
61}
62
63fn strip_sql_line_comments(line: &str) -> &str {
64 line.split_once("--").map_or(line, |(left, _)| left).trim()
65}
66
67impl Schema {
68 pub fn parse_file(path: &str) -> Result<Self, String> {
70 let content = crate::schema_source::read_qail_schema_source(path)?;
71 Self::parse(&content)
72 }
73
74 pub fn parse(content: &str) -> Result<Self, String> {
76 let mut schema = Schema::default();
77 let mut current_table: Option<String> = None;
78 let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
79 let mut current_policies: HashMap<String, String> = HashMap::new();
80 let mut current_fks: Vec<ForeignKey> = Vec::new();
81 let mut current_rls_flag = false;
82
83 for raw_line in content.lines() {
84 let line = strip_schema_comments(raw_line);
85
86 if line.is_empty() {
88 continue;
89 }
90
91 if current_table.is_none()
96 && (line.starts_with("bucket ")
97 || line.starts_with("queue ")
98 || line.starts_with("topic "))
99 {
100 let parts: Vec<&str> = line.splitn(2, ' ').collect();
101 let kind = parts[0].to_string();
102 let rest = parts.get(1).copied().unwrap_or("").trim();
103
104 let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
106 let mut provider = None;
107 let mut properties = HashMap::new();
108
109 if line.contains('{') {
110 let block = rest.split('{').nth(1).unwrap_or("").to_string();
112 if !block.contains('}') {
113 for inner in content.lines().skip_while(|l| !l.contains(line)) {
114 if inner.contains('}') {
116 break;
117 }
118 }
119 }
120 let block = block.replace('}', "");
121 let mut tokens = block.split_whitespace();
122 while let Some(key) = tokens.next() {
123 if let Some(val) = tokens.next() {
124 let val = val.trim_matches('"').to_string();
125 if key == "provider" {
126 provider = Some(val);
127 } else {
128 properties.insert(key.to_string(), val);
129 }
130 }
131 }
132 }
133
134 if !name.is_empty() {
135 schema.resources.insert(
136 name.clone(),
137 ResourceSchema {
138 name,
139 kind,
140 provider,
141 properties,
142 },
143 );
144 }
145 continue;
146 }
147
148 if current_table.is_none()
151 && let Some(view_name) = extract_view_name(line)
152 {
153 schema.views.insert(view_name.to_string());
154 continue;
155 }
156
157 if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
159 if let Some(table_name) = current_table.take() {
161 let has_rls = current_rls_flag || current_columns.contains_key("tenant_id");
163 schema.tables.insert(
164 table_name.clone(),
165 TableSchema {
166 name: table_name,
167 columns: std::mem::take(&mut current_columns),
168 policies: std::mem::take(&mut current_policies),
169 foreign_keys: std::mem::take(&mut current_fks),
170 rls_enabled: has_rls,
171 },
172 );
173 }
174
175 let after_table = line.trim_start_matches("table ");
178 let before_brace = after_table.split('{').next().unwrap_or("").trim();
179 let parts: Vec<&str> = before_brace.split_whitespace().collect();
180 let name = parts.first().unwrap_or(&"").to_string();
181 current_rls_flag = parts.contains(&"rls");
182 current_table = Some(name);
183 }
184 else if line == "}" {
186 if let Some(table_name) = current_table.take() {
187 let has_rls = current_rls_flag || current_columns.contains_key("tenant_id");
188 schema.tables.insert(
189 table_name.clone(),
190 TableSchema {
191 name: table_name,
192 columns: std::mem::take(&mut current_columns),
193 policies: std::mem::take(&mut current_policies),
194 foreign_keys: std::mem::take(&mut current_fks),
195 rls_enabled: has_rls,
196 },
197 );
198 current_rls_flag = false;
199 }
200 }
201 else if current_table.is_some() {
206 let parts: Vec<&str> = line.split_whitespace().collect();
207 if let Some(col_name) = parts.first() {
208 let col_type_str = parts.get(1).copied().unwrap_or("text");
210 let col_type = col_type_str
211 .parse::<ColumnType>()
212 .unwrap_or(ColumnType::Text);
213 current_columns.insert(col_name.to_string(), col_type);
214
215 let mut policy = "Public".to_string();
217
218 for part in parts.iter().skip(2) {
219 if *part == "protected" {
220 policy = "Protected".to_string();
221 } else if let Some(ref_spec) = part.strip_prefix("ref:") {
222 let ref_spec = ref_spec.trim_start_matches('>');
224 if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
225 current_fks.push(ForeignKey {
226 column: col_name.to_string(),
227 ref_table: ref_table.to_string(),
228 ref_column: ref_col.to_string(),
229 });
230 }
231 }
232 }
233 current_policies.insert(col_name.to_string(), policy);
234 }
235 }
236 }
237
238 if let Some(table_name) = current_table.take() {
239 return Err(format!(
240 "Unclosed table definition for '{}': expected closing '}}'",
241 table_name
242 ));
243 }
244
245 Ok(schema)
246 }
247
248 pub fn has_table(&self, name: &str) -> bool {
250 self.tables.contains_key(name) || self.views.contains(name)
251 }
252
253 pub fn rls_tables(&self) -> Vec<&str> {
255 self.tables
256 .iter()
257 .filter(|(_, ts)| ts.rls_enabled)
258 .map(|(name, _)| name.as_str())
259 .collect()
260 }
261
262 pub fn is_rls_table(&self, name: &str) -> bool {
264 self.tables.get(name).is_some_and(|t| t.rls_enabled)
265 }
266
267 pub fn table(&self, name: &str) -> Option<&TableSchema> {
269 self.tables.get(name)
270 }
271
272 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
277 use std::fs;
278
279 let dir = Path::new(migrations_dir);
280 if !dir.exists() {
281 return Ok(0); }
283
284 let mut merged_count = 0;
285
286 let entries =
288 fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
289
290 for entry in entries.flatten() {
291 let path = entry.path();
292
293 let migration_file = if path.is_dir() {
296 let up_qail = path.join("up.qail");
297 let up_sql = path.join("up.sql");
298 if up_qail.exists() {
299 up_qail
300 } else if up_sql.exists() {
301 up_sql
302 } else {
303 continue;
304 }
305 } else if path.extension().is_some_and(|e| e == "qail" || e == "sql") {
306 path.clone()
307 } else {
308 continue;
309 };
310
311 if migration_file.exists() {
312 let content = fs::read_to_string(&migration_file)
313 .map_err(|e| format!("Failed to read {}: {}", migration_file.display(), e))?;
314
315 if migration_file.extension().is_some_and(|ext| ext == "qail") {
316 merged_count += self.parse_qail_migration(&content).map_err(|e| {
317 format!(
318 "Failed to parse native migration {}: {}",
319 migration_file.display(),
320 e
321 )
322 })?;
323 } else {
324 merged_count += self.parse_sql_migration(&content);
325 }
326 }
327 }
328
329 Ok(merged_count)
330 }
331
332 pub(crate) fn parse_qail_migration(&mut self, qail: &str) -> Result<usize, String> {
334 let parsed = Schema::parse(qail)?;
335 let mut changes = 0usize;
336
337 for (table_name, parsed_table) in parsed.tables {
338 if let Some(existing) = self.tables.get_mut(&table_name) {
339 for (col_name, col_type) in parsed_table.columns {
340 if existing
341 .columns
342 .insert(col_name.clone(), col_type)
343 .is_none()
344 {
345 changes += 1;
346 }
347 }
348 for (col_name, policy) in parsed_table.policies {
349 if existing.policies.insert(col_name, policy).is_none() {
350 changes += 1;
351 }
352 }
353 for fk in parsed_table.foreign_keys {
354 let duplicate = existing.foreign_keys.iter().any(|existing_fk| {
355 existing_fk.column == fk.column
356 && existing_fk.ref_table == fk.ref_table
357 && existing_fk.ref_column == fk.ref_column
358 });
359 if !duplicate {
360 existing.foreign_keys.push(fk);
361 changes += 1;
362 }
363 }
364 if parsed_table.rls_enabled && !existing.rls_enabled {
365 existing.rls_enabled = true;
366 changes += 1;
367 }
368 } else {
369 changes += 1 + parsed_table.columns.len();
370 self.tables.insert(table_name, parsed_table);
371 }
372 }
373
374 for view_name in parsed.views {
375 if self.views.insert(view_name) {
376 changes += 1;
377 }
378 }
379 for (resource_name, resource) in parsed.resources {
380 if self.resources.insert(resource_name, resource).is_none() {
381 changes += 1;
382 }
383 }
384
385 Ok(changes)
386 }
387
388 pub(crate) fn parse_sql_migration(&mut self, sql: &str) -> usize {
390 let mut changes = 0;
391
392 for raw_line in sql.lines() {
395 let line = strip_sql_line_comments(raw_line);
396 if line.is_empty()
397 || line.starts_with("/*")
398 || line.starts_with('*')
399 || line.starts_with("*/")
400 {
401 continue;
402 }
403 let line_upper = line.to_uppercase();
404
405 if line_upper.starts_with("CREATE TABLE")
406 && let Some(table_name) = extract_create_table_name(line)
407 && !self.tables.contains_key(&table_name)
408 {
409 self.tables.insert(
410 table_name.clone(),
411 TableSchema {
412 name: table_name,
413 columns: HashMap::new(),
414 policies: HashMap::new(),
415 foreign_keys: vec![],
416 rls_enabled: false,
417 },
418 );
419 changes += 1;
420 }
421 }
422
423 let mut current_table: Option<String> = None;
429 let mut in_create_block = false;
430 let mut paren_depth = 0;
431
432 for raw_line in sql.lines() {
433 let line = strip_sql_line_comments(raw_line);
434 if line.is_empty()
435 || line.starts_with("/*")
436 || line.starts_with('*')
437 || line.starts_with("*/")
438 {
439 continue;
440 }
441 let line_upper = line.to_uppercase();
442
443 if line_upper.starts_with("CREATE TABLE")
444 && let Some(name) = extract_create_table_name(line)
445 {
446 if self.tables.get(&name).is_none_or(|t| t.columns.is_empty()) {
451 current_table = Some(name);
452 } else {
453 current_table = None;
454 }
455 in_create_block = true;
456 paren_depth = 0;
457 }
458
459 if in_create_block {
460 paren_depth += line.chars().filter(|c| *c == '(').count();
461 paren_depth =
462 paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
463
464 if let Some(col) = extract_column_from_create(line)
466 && let Some(ref table) = current_table
467 && let Some(t) = self.tables.get_mut(table)
468 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
469 {
470 changes += 1;
471 }
472
473 if paren_depth == 0 && line.contains(')') {
474 in_create_block = false;
475 current_table = None;
476 }
477 }
478
479 if line_upper.starts_with("ALTER TABLE")
481 && line_upper.contains("ADD COLUMN")
482 && let Some((table, col)) = extract_alter_add_column(line)
483 {
484 if let Some(t) = self.tables.get_mut(&table) {
485 if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
486 changes += 1;
487 }
488 } else {
489 let mut cols = HashMap::new();
491 cols.insert(col, ColumnType::Text);
492 self.tables.insert(
493 table.clone(),
494 TableSchema {
495 name: table,
496 columns: cols,
497 policies: HashMap::new(),
498 foreign_keys: vec![],
499 rls_enabled: false,
500 },
501 );
502 changes += 1;
503 }
504 }
505
506 if line_upper.starts_with("ALTER TABLE")
508 && line_upper.contains(" ADD ")
509 && !line_upper.contains("ADD COLUMN")
510 && let Some((table, col)) = extract_alter_add(line)
511 && let Some(t) = self.tables.get_mut(&table)
512 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
513 {
514 changes += 1;
515 }
516
517 if line_upper.starts_with("DROP TABLE")
519 && let Some(table_name) = extract_drop_table_name(line)
520 && self.tables.remove(&table_name).is_some()
521 {
522 changes += 1;
523 }
524
525 if line_upper.starts_with("ALTER TABLE")
527 && line_upper.contains("DROP COLUMN")
528 && let Some((table, col)) = extract_alter_drop_column(line)
529 && let Some(t) = self.tables.get_mut(&table)
530 && t.columns.remove(&col).is_some()
531 {
532 changes += 1;
533 }
534
535 if line_upper.starts_with("ALTER TABLE")
537 && line_upper.contains(" DROP ")
538 && !line_upper.contains("DROP COLUMN")
539 && !line_upper.contains("DROP CONSTRAINT")
540 && !line_upper.contains("DROP INDEX")
541 && let Some((table, col)) = extract_alter_drop(line)
542 && let Some(t) = self.tables.get_mut(&table)
543 && t.columns.remove(&col).is_some()
544 {
545 changes += 1;
546 }
547 }
548
549 changes
550 }
551}
552
553fn extract_view_name(line: &str) -> Option<&str> {
554 let rest = if let Some(r) = line.strip_prefix("view ") {
555 r
556 } else if let Some(r) = line.strip_prefix("materialized view ") {
557 r
558 } else {
559 return None;
560 };
561
562 let name = rest.split_whitespace().next().unwrap_or_default().trim();
563 if name.is_empty() { None } else { Some(name) }
564}
565
566fn extract_create_table_name(line: &str) -> Option<String> {
568 let line_upper = line.to_uppercase();
569 let rest = line_upper.strip_prefix("CREATE TABLE")?;
570 let rest = rest.trim_start();
571 let rest = if rest.starts_with("IF NOT EXISTS") {
572 rest.strip_prefix("IF NOT EXISTS")?.trim_start()
573 } else {
574 rest
575 };
576
577 let name: String = line[line.len() - rest.len()..]
579 .chars()
580 .take_while(|c| c.is_alphanumeric() || *c == '_')
581 .collect();
582
583 if name.is_empty() {
584 None
585 } else {
586 Some(name.to_lowercase())
587 }
588}
589
590fn extract_column_from_create(line: &str) -> Option<String> {
592 let line = line.trim();
593
594 let line_upper = line.to_uppercase();
599 let starts_with_keyword = |kw: &str| -> bool {
600 line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
601 };
602
603 if starts_with_keyword("CREATE")
604 || starts_with_keyword("PRIMARY")
605 || starts_with_keyword("FOREIGN")
606 || starts_with_keyword("UNIQUE")
607 || starts_with_keyword("CHECK")
608 || starts_with_keyword("CONSTRAINT")
609 || line_upper.starts_with(")")
610 || line_upper.starts_with("(")
611 || line.is_empty()
612 {
613 return None;
614 }
615
616 let name: String = line
618 .trim_start_matches('(')
619 .trim()
620 .chars()
621 .take_while(|c| c.is_alphanumeric() || *c == '_')
622 .collect();
623
624 if name.is_empty() || name.to_uppercase() == "IF" {
625 None
626 } else {
627 Some(name.to_lowercase())
628 }
629}
630
631fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
633 let line_upper = line.to_uppercase();
634 let alter_pos = line_upper.find("ALTER TABLE")?;
635 let add_pos = line_upper.find("ADD COLUMN")?;
636
637 let table_part = &line[alter_pos + 11..add_pos];
639 let table: String = table_part
640 .trim()
641 .chars()
642 .take_while(|c| c.is_alphanumeric() || *c == '_')
643 .collect();
644
645 let mut col_part = &line[add_pos + 10..];
647 let col_upper = col_part.trim().to_uppercase();
648 if col_upper.starts_with("IF NOT EXISTS") {
649 col_part = &col_part.trim()[13..]; }
651 let col: String = col_part
652 .trim()
653 .chars()
654 .take_while(|c| c.is_alphanumeric() || *c == '_')
655 .collect();
656
657 if table.is_empty() || col.is_empty() {
658 None
659 } else {
660 Some((table.to_lowercase(), col.to_lowercase()))
661 }
662}
663
664fn extract_alter_add(line: &str) -> Option<(String, String)> {
666 let line_upper = line.to_uppercase();
667 let alter_pos = line_upper.find("ALTER TABLE")?;
668 let add_pos = line_upper.find(" ADD ")?;
669
670 let table_part = &line[alter_pos + 11..add_pos];
671 let table: String = table_part
672 .trim()
673 .chars()
674 .take_while(|c| c.is_alphanumeric() || *c == '_')
675 .collect();
676
677 let col_part = &line[add_pos + 5..];
678 let col: String = col_part
679 .trim()
680 .chars()
681 .take_while(|c| c.is_alphanumeric() || *c == '_')
682 .collect();
683
684 if table.is_empty() || col.is_empty() {
685 None
686 } else {
687 Some((table.to_lowercase(), col.to_lowercase()))
688 }
689}
690
691fn extract_drop_table_name(line: &str) -> Option<String> {
693 let line_upper = line.to_uppercase();
694 let rest = line_upper.strip_prefix("DROP TABLE")?;
695 let rest = rest.trim_start();
696 let rest = if rest.starts_with("IF EXISTS") {
697 rest.strip_prefix("IF EXISTS")?.trim_start()
698 } else {
699 rest
700 };
701
702 let name: String = line[line.len() - rest.len()..]
704 .chars()
705 .take_while(|c| c.is_alphanumeric() || *c == '_')
706 .collect();
707
708 if name.is_empty() {
709 None
710 } else {
711 Some(name.to_lowercase())
712 }
713}
714
715fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
717 let line_upper = line.to_uppercase();
718 let alter_pos = line_upper.find("ALTER TABLE")?;
719 let drop_pos = line_upper.find("DROP COLUMN")?;
720
721 let table_part = &line[alter_pos + 11..drop_pos];
723 let table: String = table_part
724 .trim()
725 .chars()
726 .take_while(|c| c.is_alphanumeric() || *c == '_')
727 .collect();
728
729 let col_part = &line[drop_pos + 11..];
731 let col: String = col_part
732 .trim()
733 .chars()
734 .take_while(|c| c.is_alphanumeric() || *c == '_')
735 .collect();
736
737 if table.is_empty() || col.is_empty() {
738 None
739 } else {
740 Some((table.to_lowercase(), col.to_lowercase()))
741 }
742}
743
744fn extract_alter_drop(line: &str) -> Option<(String, String)> {
746 let line_upper = line.to_uppercase();
747 let alter_pos = line_upper.find("ALTER TABLE")?;
748 let drop_pos = line_upper.find(" DROP ")?;
749
750 let table_part = &line[alter_pos + 11..drop_pos];
751 let table: String = table_part
752 .trim()
753 .chars()
754 .take_while(|c| c.is_alphanumeric() || *c == '_')
755 .collect();
756
757 let col_part = &line[drop_pos + 6..];
758 let col: String = col_part
759 .trim()
760 .chars()
761 .take_while(|c| c.is_alphanumeric() || *c == '_')
762 .collect();
763
764 if table.is_empty() || col.is_empty() {
765 None
766 } else {
767 Some((table.to_lowercase(), col.to_lowercase()))
768 }
769}
770
771impl TableSchema {
772 pub fn has_column(&self, name: &str) -> bool {
774 self.columns.contains_key(name)
775 }
776
777 pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
779 self.columns.get(name)
780 }
781
782 pub fn primary_key_column(&self) -> &str {
788 if self.columns.contains_key("id") {
789 "id"
790 } else {
791 let singular = self.name.trim_end_matches('s');
794 let conventional = format!("{}_id", singular);
795 if self.columns.contains_key(&conventional) {
796 return "id"; }
800 "id" }
802 }
803}