1use crate::migrate::types::ColumnType;
22use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26#[derive(Debug, Clone)]
28pub struct ForeignKey {
29 pub column: String,
31 pub ref_table: String,
33 pub ref_column: String,
35}
36
37#[derive(Debug, Clone)]
39pub struct TableSchema {
40 pub name: String,
41 pub columns: HashMap<String, ColumnType>,
43 pub policies: HashMap<String, String>,
45 pub foreign_keys: Vec<ForeignKey>,
47 pub rls_enabled: bool,
50}
51
52#[derive(Debug, Default)]
54pub struct Schema {
55 pub tables: HashMap<String, TableSchema>,
56 pub resources: HashMap<String, ResourceSchema>,
58}
59
60#[derive(Debug, Clone)]
62pub struct ResourceSchema {
63 pub name: String,
64 pub kind: String,
65 pub provider: Option<String>,
66 pub properties: HashMap<String, String>,
67}
68
69impl Schema {
70 pub fn parse_file(path: &str) -> Result<Self, String> {
72 let content = fs::read_to_string(path)
73 .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
74 Self::parse(&content)
75 }
76
77 pub fn parse(content: &str) -> Result<Self, String> {
79 let mut schema = Schema::default();
80 let mut current_table: Option<String> = None;
81 let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
82 let mut current_policies: HashMap<String, String> = HashMap::new();
83 let mut current_fks: Vec<ForeignKey> = Vec::new();
84 let mut current_rls_flag = false;
85
86 for line in content.lines() {
87 let line = line.trim();
88
89 if line.is_empty() || line.starts_with('#') {
91 continue;
92 }
93
94 if line.starts_with("bucket ") || line.starts_with("queue ") || line.starts_with("topic ") {
96 let parts: Vec<&str> = line.splitn(2, ' ').collect();
97 let kind = parts[0].to_string();
98 let rest = parts.get(1).copied().unwrap_or("").trim();
99
100 let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
102 let mut provider = None;
103 let mut properties = HashMap::new();
104
105 if line.contains('{') {
106 let block = rest.split('{').nth(1).unwrap_or("").to_string();
108 if !block.contains('}') {
109 for inner in content.lines().skip_while(|l| !l.contains(line)) {
110 if inner.contains('}') { break; }
112 }
113 }
114 let block = block.replace('}', "");
115 let mut tokens = block.split_whitespace();
116 while let Some(key) = tokens.next() {
117 if let Some(val) = tokens.next() {
118 let val = val.trim_matches('"').to_string();
119 if key == "provider" {
120 provider = Some(val);
121 } else {
122 properties.insert(key.to_string(), val);
123 }
124 }
125 }
126 }
127
128 if !name.is_empty() {
129 schema.resources.insert(name.clone(), ResourceSchema {
130 name,
131 kind,
132 provider,
133 properties,
134 });
135 }
136 continue;
137 }
138
139 if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
141 if let Some(table_name) = current_table.take() {
143 let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
145 schema.tables.insert(table_name.clone(), TableSchema {
146 name: table_name,
147 columns: std::mem::take(&mut current_columns),
148 policies: std::mem::take(&mut current_policies),
149 foreign_keys: std::mem::take(&mut current_fks),
150 rls_enabled: has_rls,
151 });
152 }
153
154 let after_table = line.trim_start_matches("table ");
157 let before_brace = after_table.split('{').next().unwrap_or("").trim();
158 let parts: Vec<&str> = before_brace.split_whitespace().collect();
159 let name = parts.first().unwrap_or(&"").to_string();
160 current_rls_flag = parts.contains(&"rls");
161 current_table = Some(name);
162 }
163 else if line == "}" {
165 if let Some(table_name) = current_table.take() {
166 let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
167 schema.tables.insert(table_name.clone(), TableSchema {
168 name: table_name,
169 columns: std::mem::take(&mut current_columns),
170 policies: std::mem::take(&mut current_policies),
171 foreign_keys: std::mem::take(&mut current_fks),
172 rls_enabled: has_rls,
173 });
174 current_rls_flag = false;
175 }
176 }
177 else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
182 let parts: Vec<&str> = line.split_whitespace().collect();
183 if let Some(col_name) = parts.first() {
184 let col_type_str = parts.get(1).copied().unwrap_or("text");
186 let col_type = col_type_str.parse::<ColumnType>().unwrap_or(ColumnType::Text);
187 current_columns.insert(col_name.to_string(), col_type);
188
189 let mut policy = "Public".to_string();
191
192 for part in parts.iter().skip(2) {
193 if *part == "protected" {
194 policy = "Protected".to_string();
195 } else if let Some(ref_spec) = part.strip_prefix("ref:") {
196 let ref_spec = ref_spec.trim_start_matches('>');
198 if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
199 current_fks.push(ForeignKey {
200 column: col_name.to_string(),
201 ref_table: ref_table.to_string(),
202 ref_column: ref_col.to_string(),
203 });
204 }
205 }
206 }
207 current_policies.insert(col_name.to_string(), policy);
208 }
209 }
210 }
211
212 Ok(schema)
213 }
214
215 pub fn has_table(&self, name: &str) -> bool {
217 self.tables.contains_key(name)
218 }
219
220 pub fn rls_tables(&self) -> Vec<&str> {
222 self.tables.iter()
223 .filter(|(_, ts)| ts.rls_enabled)
224 .map(|(name, _)| name.as_str())
225 .collect()
226 }
227
228 pub fn is_rls_table(&self, name: &str) -> bool {
230 self.tables.get(name).is_some_and(|t| t.rls_enabled)
231 }
232
233 pub fn table(&self, name: &str) -> Option<&TableSchema> {
235 self.tables.get(name)
236 }
237
238 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
243 use std::fs;
244
245 let dir = Path::new(migrations_dir);
246 if !dir.exists() {
247 return Ok(0); }
249
250 let mut merged_count = 0;
251
252 let entries = fs::read_dir(dir)
254 .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
255
256 for entry in entries.flatten() {
257 let path = entry.path();
258
259 let up_sql = if path.is_dir() {
261 path.join("up.sql")
262 } else if path.extension().is_some_and(|e| e == "sql") {
263 path.clone()
264 } else {
265 continue;
266 };
267
268 if up_sql.exists() {
269 let content = fs::read_to_string(&up_sql)
270 .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
271
272 merged_count += self.parse_sql_migration(&content);
273 }
274 }
275
276 Ok(merged_count)
277 }
278
279 fn parse_sql_migration(&mut self, sql: &str) -> usize {
281 let mut changes = 0;
282
283 for line in sql.lines() {
286 let line_upper = line.trim().to_uppercase();
287
288 if line_upper.starts_with("CREATE TABLE")
289 && let Some(table_name) = extract_create_table_name(line)
290 && !self.tables.contains_key(&table_name)
291 {
292 self.tables.insert(table_name.clone(), TableSchema {
293 name: table_name,
294 columns: HashMap::new(),
295 policies: HashMap::new(),
296 foreign_keys: vec![],
297 rls_enabled: false,
298 });
299 changes += 1;
300 }
301 }
302
303 let mut current_table: Option<String> = None;
305 let mut in_create_block = false;
306 let mut paren_depth = 0;
307
308 for line in sql.lines() {
309 let line = line.trim();
310 let line_upper = line.to_uppercase();
311
312 if line_upper.starts_with("CREATE TABLE")
313 && let Some(name) = extract_create_table_name(line)
314 {
315 current_table = Some(name);
316 in_create_block = true;
317 paren_depth = 0;
318 }
319
320 if in_create_block {
321 paren_depth += line.chars().filter(|c| *c == '(').count();
322 paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
323
324 if let Some(col) = extract_column_from_create(line)
326 && let Some(ref table) = current_table
327 && let Some(t) = self.tables.get_mut(table)
328 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
329 {
330 changes += 1;
331 }
332
333 if paren_depth == 0 && line.contains(')') {
334 in_create_block = false;
335 current_table = None;
336 }
337 }
338
339 if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN")
341 && let Some((table, col)) = extract_alter_add_column(line)
342 {
343 if let Some(t) = self.tables.get_mut(&table) {
344 if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
345 changes += 1;
346 }
347 } else {
348 let mut cols = HashMap::new();
350 cols.insert(col, ColumnType::Text);
351 self.tables.insert(table.clone(), TableSchema {
352 name: table,
353 columns: cols,
354 policies: HashMap::new(),
355 foreign_keys: vec![],
356 rls_enabled: false,
357 });
358 changes += 1;
359 }
360 }
361
362 if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN")
364 && let Some((table, col)) = extract_alter_add(line)
365 && let Some(t) = self.tables.get_mut(&table)
366 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
367 {
368 changes += 1;
369 }
370
371 if line_upper.starts_with("DROP TABLE")
373 && let Some(table_name) = extract_drop_table_name(line)
374 && self.tables.remove(&table_name).is_some()
375 {
376 changes += 1;
377 }
378
379 if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN")
381 && let Some((table, col)) = extract_alter_drop_column(line)
382 && let Some(t) = self.tables.get_mut(&table)
383 && t.columns.remove(&col).is_some()
384 {
385 changes += 1;
386 }
387
388 if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ")
390 && !line_upper.contains("DROP COLUMN")
391 && !line_upper.contains("DROP CONSTRAINT")
392 && !line_upper.contains("DROP INDEX")
393 && let Some((table, col)) = extract_alter_drop(line)
394 && let Some(t) = self.tables.get_mut(&table)
395 && t.columns.remove(&col).is_some()
396 {
397 changes += 1;
398 }
399 }
400
401 changes
402 }
403}
404
405fn extract_create_table_name(line: &str) -> Option<String> {
407 let line_upper = line.to_uppercase();
408 let rest = line_upper.strip_prefix("CREATE TABLE")?;
409 let rest = rest.trim_start();
410 let rest = if rest.starts_with("IF NOT EXISTS") {
411 rest.strip_prefix("IF NOT EXISTS")?.trim_start()
412 } else {
413 rest
414 };
415
416 let name: String = line[line.len() - rest.len()..]
418 .chars()
419 .take_while(|c| c.is_alphanumeric() || *c == '_')
420 .collect();
421
422 if name.is_empty() { None } else { Some(name.to_lowercase()) }
423}
424
425fn extract_column_from_create(line: &str) -> Option<String> {
427 let line = line.trim();
428
429 let line_upper = line.to_uppercase();
434 let starts_with_keyword = |kw: &str| -> bool {
435 line_upper.starts_with(kw)
436 && line_upper[kw.len()..].starts_with([' ', '('])
437 };
438
439 if starts_with_keyword("CREATE") ||
440 starts_with_keyword("PRIMARY") ||
441 starts_with_keyword("FOREIGN") ||
442 starts_with_keyword("UNIQUE") ||
443 starts_with_keyword("CHECK") ||
444 starts_with_keyword("CONSTRAINT") ||
445 line_upper.starts_with(")") ||
446 line_upper.starts_with("(") ||
447 line.is_empty() {
448 return None;
449 }
450
451 let name: String = line
453 .trim_start_matches('(')
454 .trim()
455 .chars()
456 .take_while(|c| c.is_alphanumeric() || *c == '_')
457 .collect();
458
459 if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
460}
461
462fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
464 let line_upper = line.to_uppercase();
465 let alter_pos = line_upper.find("ALTER TABLE")?;
466 let add_pos = line_upper.find("ADD COLUMN")?;
467
468 let table_part = &line[alter_pos + 11..add_pos];
470 let table: String = table_part.trim()
471 .chars()
472 .take_while(|c| c.is_alphanumeric() || *c == '_')
473 .collect();
474
475 let col_part = &line[add_pos + 10..];
477 let col: String = col_part.trim()
478 .chars()
479 .take_while(|c| c.is_alphanumeric() || *c == '_')
480 .collect();
481
482 if table.is_empty() || col.is_empty() {
483 None
484 } else {
485 Some((table.to_lowercase(), col.to_lowercase()))
486 }
487}
488
489fn extract_alter_add(line: &str) -> Option<(String, String)> {
491 let line_upper = line.to_uppercase();
492 let alter_pos = line_upper.find("ALTER TABLE")?;
493 let add_pos = line_upper.find(" ADD ")?;
494
495 let table_part = &line[alter_pos + 11..add_pos];
496 let table: String = table_part.trim()
497 .chars()
498 .take_while(|c| c.is_alphanumeric() || *c == '_')
499 .collect();
500
501 let col_part = &line[add_pos + 5..];
502 let col: String = col_part.trim()
503 .chars()
504 .take_while(|c| c.is_alphanumeric() || *c == '_')
505 .collect();
506
507 if table.is_empty() || col.is_empty() {
508 None
509 } else {
510 Some((table.to_lowercase(), col.to_lowercase()))
511 }
512}
513
514fn extract_drop_table_name(line: &str) -> Option<String> {
516 let line_upper = line.to_uppercase();
517 let rest = line_upper.strip_prefix("DROP TABLE")?;
518 let rest = rest.trim_start();
519 let rest = if rest.starts_with("IF EXISTS") {
520 rest.strip_prefix("IF EXISTS")?.trim_start()
521 } else {
522 rest
523 };
524
525 let name: String = line[line.len() - rest.len()..]
527 .chars()
528 .take_while(|c| c.is_alphanumeric() || *c == '_')
529 .collect();
530
531 if name.is_empty() { None } else { Some(name.to_lowercase()) }
532}
533
534fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
536 let line_upper = line.to_uppercase();
537 let alter_pos = line_upper.find("ALTER TABLE")?;
538 let drop_pos = line_upper.find("DROP COLUMN")?;
539
540 let table_part = &line[alter_pos + 11..drop_pos];
542 let table: String = table_part.trim()
543 .chars()
544 .take_while(|c| c.is_alphanumeric() || *c == '_')
545 .collect();
546
547 let col_part = &line[drop_pos + 11..];
549 let col: String = col_part.trim()
550 .chars()
551 .take_while(|c| c.is_alphanumeric() || *c == '_')
552 .collect();
553
554 if table.is_empty() || col.is_empty() {
555 None
556 } else {
557 Some((table.to_lowercase(), col.to_lowercase()))
558 }
559}
560
561fn extract_alter_drop(line: &str) -> Option<(String, String)> {
563 let line_upper = line.to_uppercase();
564 let alter_pos = line_upper.find("ALTER TABLE")?;
565 let drop_pos = line_upper.find(" DROP ")?;
566
567 let table_part = &line[alter_pos + 11..drop_pos];
568 let table: String = table_part.trim()
569 .chars()
570 .take_while(|c| c.is_alphanumeric() || *c == '_')
571 .collect();
572
573 let col_part = &line[drop_pos + 6..];
574 let col: String = col_part.trim()
575 .chars()
576 .take_while(|c| c.is_alphanumeric() || *c == '_')
577 .collect();
578
579 if table.is_empty() || col.is_empty() {
580 None
581 } else {
582 Some((table.to_lowercase(), col.to_lowercase()))
583 }
584}
585
586impl TableSchema {
587 pub fn has_column(&self, name: &str) -> bool {
589 self.columns.contains_key(name)
590 }
591
592 pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
594 self.columns.get(name)
595 }
596}
597
598#[derive(Debug)]
600pub struct QailUsage {
601 pub file: String,
602 pub line: usize,
603 pub table: String,
604 pub columns: Vec<String>,
605 pub action: String,
606 pub is_cte_ref: bool,
607 pub has_rls: bool,
609}
610
611pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
613 let mut usages = Vec::new();
614 scan_directory(Path::new(src_dir), &mut usages);
615 usages
616}
617
618fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
619 if let Ok(entries) = fs::read_dir(dir) {
620 for entry in entries.flatten() {
621 let path = entry.path();
622 if path.is_dir() {
623 scan_directory(&path, usages);
624 } else if path.extension().is_some_and(|e| e == "rs")
625 && let Ok(content) = fs::read_to_string(&path)
626 {
627 scan_file(&path.display().to_string(), &content, usages);
628 }
629 }
630 }
631}
632
633fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
634 let patterns = [
636 ("Qail::get(", "GET"),
637 ("Qail::add(", "ADD"),
638 ("Qail::set(", "SET"),
639 ("Qail::del(", "DEL"),
640 ("Qail::put(", "PUT"),
641 ];
642
643 let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
646 for line in content.lines() {
647 let line = line.trim();
648 if let Some(pos) = line.find(".to_cte(") {
649 let after = &line[pos + 8..]; if let Some(name) = extract_string_arg(after) {
651 cte_names.insert(name);
652 }
653 }
654 }
655
656 let lines: Vec<&str> = content.lines().collect();
658 let mut i = 0;
659
660 while i < lines.len() {
661 let line = lines[i].trim();
662
663 for (pattern, action) in &patterns {
665 if let Some(pos) = line.find(pattern) {
666 let start_line = i + 1; let after = &line[pos + pattern.len()..];
670 if let Some(table) = extract_string_arg(after) {
671 let mut full_chain = line.to_string();
673 let mut j = i + 1;
674 while j < lines.len() {
675 let next = lines[j].trim();
676 if next.starts_with('.') {
677 full_chain.push_str(next);
678 j += 1;
679 } else if next.is_empty() {
680 j += 1; } else {
682 break;
683 }
684 }
685
686 let is_cte_ref = cte_names.contains(&table);
688
689 let has_rls = full_chain.contains(".with_rls(");
691
692 let columns = extract_columns(&full_chain);
694
695 usages.push(QailUsage {
696 file: file.to_string(),
697 line: start_line,
698 table,
699 columns,
700 action: action.to_string(),
701 is_cte_ref,
702 has_rls,
703 });
704
705 i = j.saturating_sub(1);
707 } else {
708 let var_hint = after.split(')').next().unwrap_or("?").trim();
711 println!(
712 "cargo:warning=Qail: dynamic table name `{}` in {}:{} — cannot validate columns at build time. Consider using string literals.",
713 var_hint, file, start_line
714 );
715 }
716 break; }
718 }
719 i += 1;
720 }
721}
722
723fn extract_string_arg(s: &str) -> Option<String> {
724 let s = s.trim();
726 if let Some(stripped) = s.strip_prefix('"') {
727 let end = stripped.find('"')?;
728 Some(stripped[..end].to_string())
729 } else {
730 None
731 }
732}
733
734fn extract_columns(line: &str) -> Vec<String> {
735 let mut columns = Vec::new();
736 let mut remaining = line;
737
738 while let Some(pos) = remaining.find(".column(") {
740 let after = &remaining[pos + 8..];
741 if let Some(col) = extract_string_arg(after) {
742 columns.push(col);
743 }
744 remaining = after;
745 }
746
747 remaining = line;
749 while let Some(pos) = remaining.find(".columns(") {
750 let after = &remaining[pos + 9..];
751 if let Some(bracket_start) = after.find('[') {
753 let inside = &after[bracket_start + 1..];
754 if let Some(bracket_end) = inside.find(']') {
756 let array_content = &inside[..bracket_end];
757 let mut scan = array_content;
759 while let Some(quote_start) = scan.find('"') {
760 let after_quote = &scan[quote_start + 1..];
761 if let Some(quote_end) = after_quote.find('"') {
762 let col = &after_quote[..quote_end];
763 if !col.is_empty() {
764 columns.push(col.to_string());
765 }
766 scan = &after_quote[quote_end + 1..];
767 } else {
768 break;
769 }
770 }
771 }
772 }
773 remaining = after;
774 }
775
776 remaining = line;
778
779 while let Some(pos) = remaining.find(".filter(") {
781 let after = &remaining[pos + 8..];
782 if let Some(col) = extract_string_arg(after)
783 && !col.contains('.') {
784 columns.push(col);
785 }
786 remaining = after;
787 }
788
789 for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
791 let mut temp = line;
792 while let Some(pos) = temp.find(method) {
793 let after = &temp[pos + method.len()..];
794 if let Some(col) = extract_string_arg(after)
795 && !col.contains('.') {
796 columns.push(col);
797 }
798 temp = after;
799 }
800 }
801
802 remaining = line;
804 while let Some(pos) = remaining.find(".where_eq(") {
805 let after = &remaining[pos + 10..];
806 if let Some(col) = extract_string_arg(after)
807 && !col.contains('.') {
808 columns.push(col);
809 }
810 remaining = after;
811 }
812
813 remaining = line;
815 while let Some(pos) = remaining.find(".order_by(") {
816 let after = &remaining[pos + 10..];
817 if let Some(col) = extract_string_arg(after)
818 && !col.contains('.') {
819 columns.push(col);
820 }
821 remaining = after;
822 }
823
824 for method in [".order_desc(", ".order_asc("] {
826 let mut temp = line;
827 while let Some(pos) = temp.find(method) {
828 let after = &temp[pos + method.len()..];
829 if let Some(col) = extract_string_arg(after)
830 && !col.contains('.') {
831 columns.push(col);
832 }
833 temp = after;
834 }
835 }
836
837 remaining = line;
839 while let Some(pos) = remaining.find(".in_vals(") {
840 let after = &remaining[pos + 9..];
841 if let Some(col) = extract_string_arg(after)
842 && !col.contains('.') {
843 columns.push(col);
844 }
845 remaining = after;
846 }
847
848 columns
849}
850
851pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
854 use crate::validator::Validator;
855
856 let mut validator = Validator::new();
858 for (table_name, table_schema) in &schema.tables {
859 let type_strings: Vec<(String, String)> = table_schema.columns
861 .iter()
862 .map(|(name, typ)| (name.clone(), typ.to_pg_type()))
863 .collect();
864 let cols_with_types: Vec<(&str, &str)> = type_strings
865 .iter()
866 .map(|(name, typ)| (name.as_str(), typ.as_str()))
867 .collect();
868 validator.add_table_with_types(table_name, &cols_with_types);
869 }
870
871 let mut errors = Vec::new();
872 let mut rls_warnings = Vec::new();
873
874 for usage in usages {
875 if usage.is_cte_ref {
877 continue;
878 }
879
880 match validator.validate_table(&usage.table) {
882 Ok(()) => {
883 for col in &usage.columns {
885 if col.contains('.') {
887 continue;
888 }
889 if col.contains('(') || col == "*" {
892 continue;
893 }
894
895 if let Err(e) = validator.validate_column(&usage.table, col) {
896 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
897 }
898 }
899
900 if schema.is_rls_table(&usage.table) && !usage.has_rls {
902 rls_warnings.push(format!(
903 "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
904 usage.file, usage.line, usage.action.to_lowercase(), usage.table
905 ));
906 }
907 }
908 Err(e) => {
909 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
910 }
911 }
912 }
913
914 errors.extend(rls_warnings);
916
917 errors
918}
919
920pub fn validate() {
922 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
923 if Path::new("schema.qail").exists() {
924 "schema".to_string()
925 } else {
926 "false".to_string()
927 }
928 });
929
930 match mode.as_str() {
931 "schema" => {
932 println!("cargo:rerun-if-changed=schema.qail");
933 println!("cargo:rerun-if-changed=migrations");
934 println!("cargo:rerun-if-env-changed=QAIL");
935
936 match Schema::parse_file("schema.qail") {
937 Ok(mut schema) => {
938 let merged = schema.merge_migrations("migrations").unwrap_or(0);
940 if merged > 0 {
941 println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
942 }
943
944 let usages = scan_source_files("src/");
945 let errors = validate_against_schema(&schema, &usages);
946
947 if errors.is_empty() {
948 println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
949 } else {
950 for error in &errors {
951 println!("cargo:warning=QAIL ERROR: {}", error);
952 }
953 panic!("QAIL validation failed with {} errors", errors.len());
955 }
956 }
957 Err(e) => {
958 println!("cargo:warning=QAIL: {}", e);
959 }
960 }
961 }
962 "live" => {
963 println!("cargo:rerun-if-env-changed=QAIL");
964 println!("cargo:rerun-if-env-changed=DATABASE_URL");
965
966 let db_url = match std::env::var("DATABASE_URL") {
968 Ok(url) => url,
969 Err(_) => {
970 panic!("QAIL=live requires DATABASE_URL environment variable");
971 }
972 };
973
974 println!("cargo:warning=QAIL: Pulling schema from live database...");
976
977 let pull_result = std::process::Command::new("qail")
978 .args(["pull", &db_url])
979 .output();
980
981 match pull_result {
982 Ok(output) => {
983 if !output.status.success() {
984 let stderr = String::from_utf8_lossy(&output.stderr);
985 panic!("QAIL: Failed to pull schema: {}", stderr);
986 }
987 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
988 }
989 Err(e) => {
990 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
992
993 let cargo_result = std::process::Command::new("cargo")
994 .args(["run", "-p", "qail", "--", "pull", &db_url])
995 .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
996 .output();
997
998 match cargo_result {
999 Ok(output) if output.status.success() => {
1000 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
1001 }
1002 _ => {
1003 panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
1004 }
1005 }
1006 }
1007 }
1008
1009 match Schema::parse_file("schema.qail") {
1011 Ok(mut schema) => {
1012 let merged = schema.merge_migrations("migrations").unwrap_or(0);
1014 if merged > 0 {
1015 println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
1016 }
1017
1018 let usages = scan_source_files("src/");
1019 let errors = validate_against_schema(&schema, &usages);
1020
1021 if errors.is_empty() {
1022 println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
1023 } else {
1024 for error in &errors {
1025 println!("cargo:warning=QAIL ERROR: {}", error);
1026 }
1027 panic!("QAIL validation failed with {} errors", errors.len());
1028 }
1029 }
1030 Err(e) => {
1031 panic!("QAIL: Failed to parse schema after pull: {}", e);
1032 }
1033 }
1034 }
1035 "false" | "off" | "0" => {
1036 println!("cargo:rerun-if-env-changed=QAIL");
1037 }
1039 _ => {
1040 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
1041 }
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 #[test]
1050 fn test_parse_schema() {
1051 let content = r#"
1053# Test schema
1054
1055table users {
1056 id UUID primary_key
1057 name TEXT not_null
1058 email TEXT unique
1059}
1060
1061table posts {
1062 id UUID
1063 user_id UUID
1064 title TEXT
1065}
1066"#;
1067 let schema = Schema::parse(content).unwrap();
1068 assert!(schema.has_table("users"));
1069 assert!(schema.has_table("posts"));
1070 assert!(schema.table("users").unwrap().has_column("id"));
1071 assert!(schema.table("users").unwrap().has_column("name"));
1072 assert!(!schema.table("users").unwrap().has_column("foo"));
1073 }
1074
1075 #[test]
1076 fn test_extract_string_arg() {
1077 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
1078 assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
1079 }
1080
1081 #[test]
1082 fn test_scan_file() {
1083 let content = r#"
1085let query = Qail::get("users").column("id").column("name").eq("active", true);
1086"#;
1087 let mut usages = Vec::new();
1088 scan_file("test.rs", content, &mut usages);
1089
1090 assert_eq!(usages.len(), 1);
1091 assert_eq!(usages[0].table, "users");
1092 assert_eq!(usages[0].action, "GET");
1093 assert!(usages[0].columns.contains(&"id".to_string()));
1094 assert!(usages[0].columns.contains(&"name".to_string()));
1095 }
1096
1097 #[test]
1098 fn test_scan_file_multiline() {
1099 let content = r#"
1101let query = Qail::get("posts")
1102 .column("id")
1103 .column("title")
1104 .column("author")
1105 .eq("published", true)
1106 .order_by("created_at", Desc);
1107"#;
1108 let mut usages = Vec::new();
1109 scan_file("test.rs", content, &mut usages);
1110
1111 assert_eq!(usages.len(), 1);
1112 assert_eq!(usages[0].table, "posts");
1113 assert_eq!(usages[0].action, "GET");
1114 assert!(usages[0].columns.contains(&"id".to_string()));
1115 assert!(usages[0].columns.contains(&"title".to_string()));
1116 assert!(usages[0].columns.contains(&"author".to_string()));
1117 }
1118}
1119
1120fn qail_type_to_rust(col_type: &ColumnType) -> &'static str {
1126 match col_type {
1127 ColumnType::Uuid => "uuid::Uuid",
1128 ColumnType::Text | ColumnType::Varchar(_) => "String",
1129 ColumnType::Int | ColumnType::Serial => "i32",
1130 ColumnType::BigInt | ColumnType::BigSerial => "i64",
1131 ColumnType::Bool => "bool",
1132 ColumnType::Float => "f32",
1133 ColumnType::Decimal(_) => "rust_decimal::Decimal",
1134 ColumnType::Jsonb => "serde_json::Value",
1135 ColumnType::Timestamp | ColumnType::Timestamptz => "chrono::DateTime<chrono::Utc>",
1136 ColumnType::Date => "chrono::NaiveDate",
1137 ColumnType::Time => "chrono::NaiveTime",
1138 ColumnType::Bytea => "Vec<u8>",
1139 ColumnType::Array(_) => "Vec<serde_json::Value>",
1140 ColumnType::Enum { .. } => "String",
1141 ColumnType::Range(_) => "String",
1142 ColumnType::Interval => "String",
1143 ColumnType::Cidr | ColumnType::Inet => "String",
1144 ColumnType::MacAddr => "String",
1145 }
1146}
1147
1148fn to_rust_ident(name: &str) -> String {
1150 let name = match name {
1152 "type" => "r#type",
1153 "match" => "r#match",
1154 "ref" => "r#ref",
1155 "self" => "r#self",
1156 "mod" => "r#mod",
1157 "use" => "r#use",
1158 _ => name,
1159 };
1160 name.to_string()
1161}
1162
1163fn to_struct_name(name: &str) -> String {
1165 name.chars()
1166 .next()
1167 .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
1168 .unwrap_or_default()
1169}
1170
1171pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1187 let schema = Schema::parse_file(schema_path)?;
1188 let code = generate_schema_code(&schema);
1189
1190 fs::write(output_path, code)
1191 .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1192
1193 Ok(())
1194}
1195
1196pub fn generate_schema_code(schema: &Schema) -> String {
1198 let mut code = String::new();
1199
1200 code.push_str("//! Auto-generated typed schema from schema.qail\n");
1202 code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1203 code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1204 code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1205
1206 let mut tables: Vec<_> = schema.tables.values().collect();
1208 tables.sort_by(|a, b| a.name.cmp(&b.name));
1209
1210 for table in &tables {
1211 let mod_name = to_rust_ident(&table.name);
1212 let struct_name = to_struct_name(&table.name);
1213
1214 code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1215 code.push_str(&format!("pub mod {} {{\n", mod_name));
1216 code.push_str(" use super::*;\n\n");
1217
1218 code.push_str(&format!(" /// Table marker for `{}`\n", table.name));
1220 code.push_str(" #[derive(Debug, Clone, Copy)]\n");
1221 code.push_str(&format!(" pub struct {};\n\n", struct_name));
1222
1223 code.push_str(&format!(" impl Table for {} {{\n", struct_name));
1224 code.push_str(&format!(" fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1225 code.push_str(" }\n\n");
1226
1227 code.push_str(&format!(" impl From<{}> for String {{\n", struct_name));
1228 code.push_str(&format!(" fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1229 code.push_str(" }\n\n");
1230
1231 code.push_str(&format!(" impl AsRef<str> for {} {{\n", struct_name));
1232 code.push_str(&format!(" fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1233 code.push_str(" }\n\n");
1234
1235 code.push_str(&format!(" /// The `{}` table\n", table.name));
1237 code.push_str(&format!(" pub const table: {} = {};\n\n", struct_name, struct_name));
1238
1239 let mut columns: Vec<_> = table.columns.iter().collect();
1241 columns.sort_by(|a, b| a.0.cmp(b.0));
1242
1243 for (col_name, col_type) in columns {
1245 let rust_type = qail_type_to_rust(col_type);
1246 let col_ident = to_rust_ident(col_name);
1247 let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1248 let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1249
1250 code.push_str(&format!(" /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type.to_pg_type(), policy));
1251 code.push_str(&format!(
1252 " pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1253 col_ident, rust_type, rust_policy, table.name, col_name
1254 ));
1255 }
1256
1257 code.push_str("}\n\n");
1258 }
1259
1260 code.push_str("// =============================================================================\n");
1265 code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1266 code.push_str("// =============================================================================\n\n");
1267
1268 for table in &tables {
1269 for fk in &table.foreign_keys {
1270 let from_mod = to_rust_ident(&table.name);
1275 let from_struct = to_struct_name(&table.name);
1276 let to_mod = to_rust_ident(&fk.ref_table);
1277 let to_struct = to_struct_name(&fk.ref_table);
1278
1279 code.push_str(&format!(
1282 "/// {} has a foreign key to {} via {}.{}\n",
1283 table.name, fk.ref_table, table.name, fk.column
1284 ));
1285 code.push_str(&format!(
1286 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1287 to_mod, to_struct, from_mod, from_struct
1288 ));
1289 code.push_str(&format!(
1290 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1291 fk.column, fk.ref_column
1292 ));
1293 code.push_str("}\n\n");
1294
1295 code.push_str(&format!(
1299 "/// {} is referenced by {} via {}.{}\n",
1300 fk.ref_table, table.name, table.name, fk.column
1301 ));
1302 code.push_str(&format!(
1303 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1304 from_mod, from_struct, to_mod, to_struct
1305 ));
1306 code.push_str(&format!(
1307 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1308 fk.ref_column, fk.column
1309 ));
1310 code.push_str("}\n\n");
1311 }
1312 }
1313
1314 code
1315}
1316
1317#[cfg(test)]
1318mod codegen_tests {
1319 use super::*;
1320
1321 #[test]
1322 fn test_generate_schema_code() {
1323 let schema_content = r#"
1324table users {
1325 id UUID primary_key
1326 email TEXT not_null
1327 age INT
1328}
1329
1330table posts {
1331 id UUID primary_key
1332 user_id UUID ref:users.id
1333 title TEXT
1334}
1335"#;
1336
1337 let schema = Schema::parse(schema_content).unwrap();
1338 let code = generate_schema_code(&schema);
1339
1340 assert!(code.contains("pub mod users {"));
1342 assert!(code.contains("pub mod posts {"));
1343
1344 assert!(code.contains("pub struct Users;"));
1346 assert!(code.contains("pub struct Posts;"));
1347
1348 assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1350 assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1351 assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1352
1353 assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1355 assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1356 }
1357
1358 #[test]
1359 fn test_generate_protected_column() {
1360 let schema_content = r#"
1361table secrets {
1362 id UUID primary_key
1363 token TEXT protected
1364}
1365"#;
1366 let schema = Schema::parse(schema_content).unwrap();
1367 let code = generate_schema_code(&schema);
1368
1369 assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1371 }
1372}
1373
1374
1375
1376#[cfg(test)]
1377mod migration_parser_tests {
1378 use super::*;
1379
1380 #[test]
1381 fn test_agent_contracts_migration_parses_all_columns() {
1382 let sql = r#"
1383CREATE TABLE agent_contracts (
1384 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1385 agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1386 operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1387 pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1388 commission_percent DECIMAL(5,2),
1389 static_markup DECIMAL(10,2),
1390 is_active BOOLEAN DEFAULT true,
1391 valid_from DATE,
1392 valid_until DATE,
1393 approved_by UUID REFERENCES users(id),
1394 created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1395 updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1396 UNIQUE(agent_id, operator_id)
1397);
1398"#;
1399
1400 let mut schema = Schema::default();
1401 schema.parse_sql_migration(sql);
1402
1403 let table = schema.tables.get("agent_contracts")
1404 .expect("agent_contracts table should exist");
1405
1406 for col in &["id", "agent_id", "operator_id", "pricing_model",
1407 "commission_percent", "static_markup", "is_active",
1408 "valid_from", "valid_until", "approved_by",
1409 "created_at", "updated_at"] {
1410 assert!(
1411 table.columns.contains_key(*col),
1412 "Missing column: '{}'. Found: {:?}",
1413 col, table.columns.keys().collect::<Vec<_>>()
1414 );
1415 }
1416 }
1417
1418 #[test]
1421 fn test_keyword_prefixed_column_names_are_not_skipped() {
1422 let sql = r#"
1423CREATE TABLE edge_cases (
1424 id UUID PRIMARY KEY,
1425 created_at TIMESTAMPTZ NOT NULL,
1426 created_by UUID,
1427 primary_contact VARCHAR(255),
1428 check_status VARCHAR(20),
1429 unique_code VARCHAR(50),
1430 foreign_ref UUID,
1431 constraint_name VARCHAR(100),
1432 PRIMARY KEY (id),
1433 CHECK (check_status IN ('pending', 'active')),
1434 UNIQUE (unique_code),
1435 CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1436);
1437"#;
1438
1439 let mut schema = Schema::default();
1440 schema.parse_sql_migration(sql);
1441
1442 let table = schema.tables.get("edge_cases")
1443 .expect("edge_cases table should exist");
1444
1445 for col in &["created_at", "created_by", "primary_contact",
1447 "check_status", "unique_code", "foreign_ref",
1448 "constraint_name"] {
1449 assert!(
1450 table.columns.contains_key(*col),
1451 "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1452 col, table.columns.keys().collect::<Vec<_>>()
1453 );
1454 }
1455
1456 assert!(!table.columns.contains_key("primary"),
1459 "Constraint keyword 'PRIMARY' should not be treated as a column");
1460 }
1461}