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