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 }
650 break; }
652 }
653 i += 1;
654 }
655}
656
657fn extract_string_arg(s: &str) -> Option<String> {
658 let s = s.trim();
660 if let Some(stripped) = s.strip_prefix('"') {
661 let end = stripped.find('"')?;
662 Some(stripped[..end].to_string())
663 } else {
664 None
665 }
666}
667
668fn extract_columns(line: &str) -> Vec<String> {
669 let mut columns = Vec::new();
670 let mut remaining = line;
671
672 while let Some(pos) = remaining.find(".column(") {
674 let after = &remaining[pos + 8..];
675 if let Some(col) = extract_string_arg(after) {
676 columns.push(col);
677 }
678 remaining = after;
679 }
680
681 remaining = line;
683
684 while let Some(pos) = remaining.find(".filter(") {
686 let after = &remaining[pos + 8..];
687 if let Some(col) = extract_string_arg(after)
688 && !col.contains('.') {
689 columns.push(col);
690 }
691 remaining = after;
692 }
693
694 for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
696 let mut temp = line;
697 while let Some(pos) = temp.find(method) {
698 let after = &temp[pos + method.len()..];
699 if let Some(col) = extract_string_arg(after)
700 && !col.contains('.') {
701 columns.push(col);
702 }
703 temp = after;
704 }
705 }
706
707 let mut remaining = line;
709 while let Some(pos) = remaining.find(".order_by(") {
710 let after = &remaining[pos + 10..];
711 if let Some(col) = extract_string_arg(after)
712 && !col.contains('.') {
713 columns.push(col);
714 }
715 remaining = after;
716 }
717
718 columns
719}
720
721pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
724 use crate::validator::Validator;
725
726 let mut validator = Validator::new();
728 for (table_name, table_schema) in &schema.tables {
729 let cols_with_types: Vec<(&str, &str)> = table_schema.columns
731 .iter()
732 .map(|(name, typ)| (name.as_str(), typ.as_str()))
733 .collect();
734 validator.add_table_with_types(table_name, &cols_with_types);
735 }
736
737 let mut errors = Vec::new();
738 let mut rls_warnings = Vec::new();
739
740 for usage in usages {
741 if usage.is_cte_ref {
743 continue;
744 }
745
746 match validator.validate_table(&usage.table) {
748 Ok(()) => {
749 for col in &usage.columns {
751 if col.contains('.') {
753 continue;
754 }
755
756 if let Err(e) = validator.validate_column(&usage.table, col) {
757 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
758 }
759 }
760
761 if schema.is_rls_table(&usage.table) && !usage.has_rls {
763 rls_warnings.push(format!(
764 "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
765 usage.file, usage.line, usage.action.to_lowercase(), usage.table
766 ));
767 }
768 }
769 Err(e) => {
770 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
771 }
772 }
773 }
774
775 errors.extend(rls_warnings);
777
778 errors
779}
780
781pub fn validate() {
783 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
784 if Path::new("schema.qail").exists() {
785 "schema".to_string()
786 } else {
787 "false".to_string()
788 }
789 });
790
791 match mode.as_str() {
792 "schema" => {
793 println!("cargo:rerun-if-changed=schema.qail");
794 println!("cargo:rerun-if-changed=migrations");
795 println!("cargo:rerun-if-env-changed=QAIL");
796
797 match Schema::parse_file("schema.qail") {
798 Ok(mut schema) => {
799 let merged = schema.merge_migrations("migrations").unwrap_or(0);
801 if merged > 0 {
802 println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
803 }
804
805 let usages = scan_source_files("src/");
806 let errors = validate_against_schema(&schema, &usages);
807
808 if errors.is_empty() {
809 println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
810 } else {
811 for error in &errors {
812 println!("cargo:warning=QAIL ERROR: {}", error);
813 }
814 panic!("QAIL validation failed with {} errors", errors.len());
816 }
817 }
818 Err(e) => {
819 println!("cargo:warning=QAIL: {}", e);
820 }
821 }
822 }
823 "live" => {
824 println!("cargo:rerun-if-env-changed=QAIL");
825 println!("cargo:rerun-if-env-changed=DATABASE_URL");
826
827 let db_url = match std::env::var("DATABASE_URL") {
829 Ok(url) => url,
830 Err(_) => {
831 panic!("QAIL=live requires DATABASE_URL environment variable");
832 }
833 };
834
835 println!("cargo:warning=QAIL: Pulling schema from live database...");
837
838 let pull_result = std::process::Command::new("qail")
839 .args(["pull", &db_url])
840 .output();
841
842 match pull_result {
843 Ok(output) => {
844 if !output.status.success() {
845 let stderr = String::from_utf8_lossy(&output.stderr);
846 panic!("QAIL: Failed to pull schema: {}", stderr);
847 }
848 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
849 }
850 Err(e) => {
851 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
853
854 let cargo_result = std::process::Command::new("cargo")
855 .args(["run", "-p", "qail", "--", "pull", &db_url])
856 .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
857 .output();
858
859 match cargo_result {
860 Ok(output) if output.status.success() => {
861 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
862 }
863 _ => {
864 panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
865 }
866 }
867 }
868 }
869
870 match Schema::parse_file("schema.qail") {
872 Ok(mut schema) => {
873 let merged = schema.merge_migrations("migrations").unwrap_or(0);
875 if merged > 0 {
876 println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
877 }
878
879 let usages = scan_source_files("src/");
880 let errors = validate_against_schema(&schema, &usages);
881
882 if errors.is_empty() {
883 println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
884 } else {
885 for error in &errors {
886 println!("cargo:warning=QAIL ERROR: {}", error);
887 }
888 panic!("QAIL validation failed with {} errors", errors.len());
889 }
890 }
891 Err(e) => {
892 panic!("QAIL: Failed to parse schema after pull: {}", e);
893 }
894 }
895 }
896 "false" | "off" | "0" => {
897 println!("cargo:rerun-if-env-changed=QAIL");
898 }
900 _ => {
901 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
902 }
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 #[test]
911 fn test_parse_schema() {
912 let content = r#"
914# Test schema
915
916table users {
917 id UUID primary_key
918 name TEXT not_null
919 email TEXT unique
920}
921
922table posts {
923 id UUID
924 user_id UUID
925 title TEXT
926}
927"#;
928 let schema = Schema::parse(content).unwrap();
929 assert!(schema.has_table("users"));
930 assert!(schema.has_table("posts"));
931 assert!(schema.table("users").unwrap().has_column("id"));
932 assert!(schema.table("users").unwrap().has_column("name"));
933 assert!(!schema.table("users").unwrap().has_column("foo"));
934 }
935
936 #[test]
937 fn test_extract_string_arg() {
938 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
939 assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
940 }
941
942 #[test]
943 fn test_scan_file() {
944 let content = r#"
946let query = Qail::get("users").column("id").column("name").eq("active", true);
947"#;
948 let mut usages = Vec::new();
949 scan_file("test.rs", content, &mut usages);
950
951 assert_eq!(usages.len(), 1);
952 assert_eq!(usages[0].table, "users");
953 assert_eq!(usages[0].action, "GET");
954 assert!(usages[0].columns.contains(&"id".to_string()));
955 assert!(usages[0].columns.contains(&"name".to_string()));
956 }
957
958 #[test]
959 fn test_scan_file_multiline() {
960 let content = r#"
962let query = Qail::get("posts")
963 .column("id")
964 .column("title")
965 .column("author")
966 .eq("published", true)
967 .order_by("created_at", Desc);
968"#;
969 let mut usages = Vec::new();
970 scan_file("test.rs", content, &mut usages);
971
972 assert_eq!(usages.len(), 1);
973 assert_eq!(usages[0].table, "posts");
974 assert_eq!(usages[0].action, "GET");
975 assert!(usages[0].columns.contains(&"id".to_string()));
976 assert!(usages[0].columns.contains(&"title".to_string()));
977 assert!(usages[0].columns.contains(&"author".to_string()));
978 }
979}
980
981fn qail_type_to_rust(qail_type: &str) -> &'static str {
987 match qail_type.to_uppercase().as_str() {
988 "UUID" => "uuid::Uuid",
989 "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
990 "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
991 "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
992 "SMALLINT" | "INT2" => "i16",
993 "FLOAT" | "FLOAT4" | "REAL" => "f32",
994 "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
995 "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
996 "BOOL" | "BOOLEAN" => "bool",
997 "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
998 "DATE" => "chrono::NaiveDate",
999 "TIME" | "TIMETZ" => "chrono::NaiveTime",
1000 "JSON" | "JSONB" => "serde_json::Value",
1001 "BYTEA" | "BLOB" => "Vec<u8>",
1002 _ => "String", }
1004}
1005
1006fn to_rust_ident(name: &str) -> String {
1008 let name = match name {
1010 "type" => "r#type",
1011 "match" => "r#match",
1012 "ref" => "r#ref",
1013 "self" => "r#self",
1014 "mod" => "r#mod",
1015 "use" => "r#use",
1016 _ => name,
1017 };
1018 name.to_string()
1019}
1020
1021fn to_struct_name(name: &str) -> String {
1023 name.chars()
1024 .next()
1025 .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
1026 .unwrap_or_default()
1027}
1028
1029pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1045 let schema = Schema::parse_file(schema_path)?;
1046 let code = generate_schema_code(&schema);
1047
1048 fs::write(output_path, code)
1049 .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1050
1051 Ok(())
1052}
1053
1054pub fn generate_schema_code(schema: &Schema) -> String {
1056 let mut code = String::new();
1057
1058 code.push_str("//! Auto-generated typed schema from schema.qail\n");
1060 code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1061 code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1062 code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1063
1064 let mut tables: Vec<_> = schema.tables.values().collect();
1066 tables.sort_by(|a, b| a.name.cmp(&b.name));
1067
1068 for table in &tables {
1069 let mod_name = to_rust_ident(&table.name);
1070 let struct_name = to_struct_name(&table.name);
1071
1072 code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1073 code.push_str(&format!("pub mod {} {{\n", mod_name));
1074 code.push_str(" use super::*;\n\n");
1075
1076 code.push_str(&format!(" /// Table marker for `{}`\n", table.name));
1078 code.push_str(" #[derive(Debug, Clone, Copy)]\n");
1079 code.push_str(&format!(" pub struct {};\n\n", struct_name));
1080
1081 code.push_str(&format!(" impl Table for {} {{\n", struct_name));
1082 code.push_str(&format!(" fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1083 code.push_str(" }\n\n");
1084
1085 code.push_str(&format!(" impl From<{}> for String {{\n", struct_name));
1086 code.push_str(&format!(" fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1087 code.push_str(" }\n\n");
1088
1089 code.push_str(&format!(" impl AsRef<str> for {} {{\n", struct_name));
1090 code.push_str(&format!(" fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1091 code.push_str(" }\n\n");
1092
1093 code.push_str(&format!(" /// The `{}` table\n", table.name));
1095 code.push_str(&format!(" pub const table: {} = {};\n\n", struct_name, struct_name));
1096
1097 let mut columns: Vec<_> = table.columns.iter().collect();
1099 columns.sort_by(|a, b| a.0.cmp(b.0));
1100
1101 for (col_name, col_type) in columns {
1103 let rust_type = qail_type_to_rust(col_type);
1104 let col_ident = to_rust_ident(col_name);
1105 let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1106 let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1107
1108 code.push_str(&format!(" /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1109 code.push_str(&format!(
1110 " pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1111 col_ident, rust_type, rust_policy, table.name, col_name
1112 ));
1113 }
1114
1115 code.push_str("}\n\n");
1116 }
1117
1118 code.push_str("// =============================================================================\n");
1123 code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1124 code.push_str("// =============================================================================\n\n");
1125
1126 for table in &tables {
1127 for fk in &table.foreign_keys {
1128 let from_mod = to_rust_ident(&table.name);
1133 let from_struct = to_struct_name(&table.name);
1134 let to_mod = to_rust_ident(&fk.ref_table);
1135 let to_struct = to_struct_name(&fk.ref_table);
1136
1137 code.push_str(&format!(
1140 "/// {} has a foreign key to {} via {}.{}\n",
1141 table.name, fk.ref_table, table.name, fk.column
1142 ));
1143 code.push_str(&format!(
1144 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1145 to_mod, to_struct, from_mod, from_struct
1146 ));
1147 code.push_str(&format!(
1148 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1149 fk.column, fk.ref_column
1150 ));
1151 code.push_str("}\n\n");
1152
1153 code.push_str(&format!(
1157 "/// {} is referenced by {} via {}.{}\n",
1158 fk.ref_table, table.name, table.name, fk.column
1159 ));
1160 code.push_str(&format!(
1161 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1162 from_mod, from_struct, to_mod, to_struct
1163 ));
1164 code.push_str(&format!(
1165 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1166 fk.ref_column, fk.column
1167 ));
1168 code.push_str("}\n\n");
1169 }
1170 }
1171
1172 code
1173}
1174
1175#[cfg(test)]
1176mod codegen_tests {
1177 use super::*;
1178
1179 #[test]
1180 fn test_generate_schema_code() {
1181 let schema_content = r#"
1182table users {
1183 id UUID primary_key
1184 email TEXT not_null
1185 age INT
1186}
1187
1188table posts {
1189 id UUID primary_key
1190 user_id UUID ref:users.id
1191 title TEXT
1192}
1193"#;
1194
1195 let schema = Schema::parse(schema_content).unwrap();
1196 let code = generate_schema_code(&schema);
1197
1198 assert!(code.contains("pub mod users {"));
1200 assert!(code.contains("pub mod posts {"));
1201
1202 assert!(code.contains("pub struct Users;"));
1204 assert!(code.contains("pub struct Posts;"));
1205
1206 assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1208 assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1209 assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1210
1211 assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1213 assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1214 }
1215
1216 #[test]
1217 fn test_generate_protected_column() {
1218 let schema_content = r#"
1219table secrets {
1220 id UUID primary_key
1221 token TEXT protected
1222}
1223"#;
1224 let schema = Schema::parse(schema_content).unwrap();
1225 let code = generate_schema_code(&schema);
1226
1227 assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1229 }
1230}
1231
1232
1233
1234#[cfg(test)]
1235mod migration_parser_tests {
1236 use super::*;
1237
1238 #[test]
1239 fn test_agent_contracts_migration_parses_all_columns() {
1240 let sql = r#"
1241CREATE TABLE agent_contracts (
1242 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1243 agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1244 operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1245 pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1246 commission_percent DECIMAL(5,2),
1247 static_markup DECIMAL(10,2),
1248 is_active BOOLEAN DEFAULT true,
1249 valid_from DATE,
1250 valid_until DATE,
1251 approved_by UUID REFERENCES users(id),
1252 created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1253 updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1254 UNIQUE(agent_id, operator_id)
1255);
1256"#;
1257
1258 let mut schema = Schema::default();
1259 schema.parse_sql_migration(sql);
1260
1261 let table = schema.tables.get("agent_contracts")
1262 .expect("agent_contracts table should exist");
1263
1264 for col in &["id", "agent_id", "operator_id", "pricing_model",
1265 "commission_percent", "static_markup", "is_active",
1266 "valid_from", "valid_until", "approved_by",
1267 "created_at", "updated_at"] {
1268 assert!(
1269 table.columns.contains_key(*col),
1270 "Missing column: '{}'. Found: {:?}",
1271 col, table.columns.keys().collect::<Vec<_>>()
1272 );
1273 }
1274 }
1275
1276 #[test]
1279 fn test_keyword_prefixed_column_names_are_not_skipped() {
1280 let sql = r#"
1281CREATE TABLE edge_cases (
1282 id UUID PRIMARY KEY,
1283 created_at TIMESTAMPTZ NOT NULL,
1284 created_by UUID,
1285 primary_contact VARCHAR(255),
1286 check_status VARCHAR(20),
1287 unique_code VARCHAR(50),
1288 foreign_ref UUID,
1289 constraint_name VARCHAR(100),
1290 PRIMARY KEY (id),
1291 CHECK (check_status IN ('pending', 'active')),
1292 UNIQUE (unique_code),
1293 CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1294);
1295"#;
1296
1297 let mut schema = Schema::default();
1298 schema.parse_sql_migration(sql);
1299
1300 let table = schema.tables.get("edge_cases")
1301 .expect("edge_cases table should exist");
1302
1303 for col in &["created_at", "created_by", "primary_contact",
1305 "check_status", "unique_code", "foreign_ref",
1306 "constraint_name"] {
1307 assert!(
1308 table.columns.contains_key(*col),
1309 "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1310 col, table.columns.keys().collect::<Vec<_>>()
1311 );
1312 }
1313
1314 assert!(!table.columns.contains_key("primary"),
1317 "Constraint keyword 'PRIMARY' should not be treated as a column");
1318 }
1319}