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