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}
47
48#[derive(Debug, Default)]
50pub struct Schema {
51 pub tables: HashMap<String, TableSchema>,
52}
53
54impl Schema {
55 pub fn parse_file(path: &str) -> Result<Self, String> {
57 let content = fs::read_to_string(path)
58 .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
59 Self::parse(&content)
60 }
61
62 pub fn parse(content: &str) -> Result<Self, String> {
64 let mut schema = Schema::default();
65 let mut current_table: Option<String> = None;
66 let mut current_columns: HashMap<String, String> = HashMap::new();
67 let mut current_policies: HashMap<String, String> = HashMap::new();
68 let mut current_fks: Vec<ForeignKey> = Vec::new();
69
70 for line in content.lines() {
71 let line = line.trim();
72
73 if line.is_empty() || line.starts_with('#') {
75 continue;
76 }
77
78 if line.starts_with("table ") && line.ends_with('{') {
80 if let Some(table_name) = current_table.take() {
82 schema.tables.insert(table_name.clone(), TableSchema {
83 name: table_name,
84 columns: std::mem::take(&mut current_columns),
85 policies: std::mem::take(&mut current_policies),
86 foreign_keys: std::mem::take(&mut current_fks),
87 });
88 }
89
90 let name = line.trim_start_matches("table ")
92 .trim_end_matches('{')
93 .trim()
94 .to_string();
95 current_table = Some(name);
96 }
97 else if line == "}" {
99 if let Some(table_name) = current_table.take() {
100 schema.tables.insert(table_name.clone(), TableSchema {
101 name: table_name,
102 columns: std::mem::take(&mut current_columns),
103 policies: std::mem::take(&mut current_policies),
104 foreign_keys: std::mem::take(&mut current_fks),
105 });
106 }
107 }
108 else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
113 let parts: Vec<&str> = line.split_whitespace().collect();
114 if let Some(col_name) = parts.first() {
115 let col_type = parts.get(1).copied().unwrap_or("TEXT").to_uppercase();
117 current_columns.insert(col_name.to_string(), col_type);
118
119 let mut policy = "Public".to_string();
121
122 for part in parts.iter().skip(2) {
123 if *part == "protected" {
124 policy = "Protected".to_string();
125 } else if let Some(ref_spec) = part.strip_prefix("ref:") {
126 let ref_spec = ref_spec.trim_start_matches('>');
128 if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
129 current_fks.push(ForeignKey {
130 column: col_name.to_string(),
131 ref_table: ref_table.to_string(),
132 ref_column: ref_col.to_string(),
133 });
134 }
135 }
136 }
137 current_policies.insert(col_name.to_string(), policy);
138 }
139 }
140 }
141
142 Ok(schema)
143 }
144
145 pub fn has_table(&self, name: &str) -> bool {
147 self.tables.contains_key(name)
148 }
149
150 pub fn table(&self, name: &str) -> Option<&TableSchema> {
152 self.tables.get(name)
153 }
154
155 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
160 use std::fs;
161
162 let dir = Path::new(migrations_dir);
163 if !dir.exists() {
164 return Ok(0); }
166
167 let mut merged_count = 0;
168
169 let entries = fs::read_dir(dir)
171 .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
172
173 for entry in entries.flatten() {
174 let path = entry.path();
175
176 let up_sql = if path.is_dir() {
178 path.join("up.sql")
179 } else if path.extension().is_some_and(|e| e == "sql") {
180 path.clone()
181 } else {
182 continue;
183 };
184
185 if up_sql.exists() {
186 let content = fs::read_to_string(&up_sql)
187 .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
188
189 merged_count += self.parse_sql_migration(&content);
190 }
191 }
192
193 Ok(merged_count)
194 }
195
196 fn parse_sql_migration(&mut self, sql: &str) -> usize {
198 let mut changes = 0;
199
200 for line in sql.lines() {
203 let line_upper = line.trim().to_uppercase();
204
205 if line_upper.starts_with("CREATE TABLE")
206 && let Some(table_name) = extract_create_table_name(line)
207 && !self.tables.contains_key(&table_name)
208 {
209 self.tables.insert(table_name.clone(), TableSchema {
210 name: table_name,
211 columns: HashMap::new(),
212 policies: HashMap::new(),
213 foreign_keys: vec![],
214 });
215 changes += 1;
216 }
217 }
218
219 let mut current_table: Option<String> = None;
221 let mut in_create_block = false;
222 let mut paren_depth = 0;
223
224 for line in sql.lines() {
225 let line = line.trim();
226 let line_upper = line.to_uppercase();
227
228 if line_upper.starts_with("CREATE TABLE")
229 && let Some(name) = extract_create_table_name(line)
230 {
231 current_table = Some(name);
232 in_create_block = true;
233 paren_depth = 0;
234 }
235
236 if in_create_block {
237 paren_depth += line.chars().filter(|c| *c == '(').count();
238 paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
239
240 if let Some(col) = extract_column_from_create(line)
242 && let Some(ref table) = current_table
243 && let Some(t) = self.tables.get_mut(table)
244 && t.columns.insert(col.clone(), "TEXT".to_string()).is_none()
245 {
246 changes += 1;
247 }
248
249 if paren_depth == 0 && line.contains(')') {
250 in_create_block = false;
251 current_table = None;
252 }
253 }
254
255 if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN")
257 && let Some((table, col)) = extract_alter_add_column(line)
258 {
259 if let Some(t) = self.tables.get_mut(&table) {
260 if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
261 changes += 1;
262 }
263 } else {
264 let mut cols = HashMap::new();
266 cols.insert(col, "TEXT".to_string());
267 self.tables.insert(table.clone(), TableSchema {
268 name: table,
269 columns: cols,
270 policies: HashMap::new(),
271 foreign_keys: vec![],
272 });
273 changes += 1;
274 }
275 }
276
277 if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN")
279 && let Some((table, col)) = extract_alter_add(line)
280 && let Some(t) = self.tables.get_mut(&table)
281 && t.columns.insert(col.clone(), "TEXT".to_string()).is_none()
282 {
283 changes += 1;
284 }
285
286 if line_upper.starts_with("DROP TABLE")
288 && let Some(table_name) = extract_drop_table_name(line)
289 && self.tables.remove(&table_name).is_some()
290 {
291 changes += 1;
292 }
293
294 if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN")
296 && let Some((table, col)) = extract_alter_drop_column(line)
297 && let Some(t) = self.tables.get_mut(&table)
298 && t.columns.remove(&col).is_some()
299 {
300 changes += 1;
301 }
302
303 if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ")
305 && !line_upper.contains("DROP COLUMN")
306 && !line_upper.contains("DROP CONSTRAINT")
307 && !line_upper.contains("DROP INDEX")
308 && let Some((table, col)) = extract_alter_drop(line)
309 && let Some(t) = self.tables.get_mut(&table)
310 && t.columns.remove(&col).is_some()
311 {
312 changes += 1;
313 }
314 }
315
316 changes
317 }
318}
319
320fn extract_create_table_name(line: &str) -> Option<String> {
322 let line_upper = line.to_uppercase();
323 let rest = line_upper.strip_prefix("CREATE TABLE")?;
324 let rest = rest.trim_start();
325 let rest = if rest.starts_with("IF NOT EXISTS") {
326 rest.strip_prefix("IF NOT EXISTS")?.trim_start()
327 } else {
328 rest
329 };
330
331 let name: String = line[line.len() - rest.len()..]
333 .chars()
334 .take_while(|c| c.is_alphanumeric() || *c == '_')
335 .collect();
336
337 if name.is_empty() { None } else { Some(name.to_lowercase()) }
338}
339
340fn extract_column_from_create(line: &str) -> Option<String> {
342 let line = line.trim();
343
344 let line_upper = line.to_uppercase();
349 let starts_with_keyword = |kw: &str| -> bool {
350 line_upper.starts_with(kw)
351 && line_upper[kw.len()..].starts_with(|c: char| c == ' ' || c == '(')
352 };
353
354 if starts_with_keyword("CREATE") ||
355 starts_with_keyword("PRIMARY") ||
356 starts_with_keyword("FOREIGN") ||
357 starts_with_keyword("UNIQUE") ||
358 starts_with_keyword("CHECK") ||
359 starts_with_keyword("CONSTRAINT") ||
360 line_upper.starts_with(")") ||
361 line_upper.starts_with("(") ||
362 line.is_empty() {
363 return None;
364 }
365
366 let name: String = line
368 .trim_start_matches('(')
369 .trim()
370 .chars()
371 .take_while(|c| c.is_alphanumeric() || *c == '_')
372 .collect();
373
374 if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
375}
376
377fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
379 let line_upper = line.to_uppercase();
380 let alter_pos = line_upper.find("ALTER TABLE")?;
381 let add_pos = line_upper.find("ADD COLUMN")?;
382
383 let table_part = &line[alter_pos + 11..add_pos];
385 let table: String = table_part.trim()
386 .chars()
387 .take_while(|c| c.is_alphanumeric() || *c == '_')
388 .collect();
389
390 let col_part = &line[add_pos + 10..];
392 let col: String = col_part.trim()
393 .chars()
394 .take_while(|c| c.is_alphanumeric() || *c == '_')
395 .collect();
396
397 if table.is_empty() || col.is_empty() {
398 None
399 } else {
400 Some((table.to_lowercase(), col.to_lowercase()))
401 }
402}
403
404fn extract_alter_add(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 ")?;
409
410 let table_part = &line[alter_pos + 11..add_pos];
411 let table: String = table_part.trim()
412 .chars()
413 .take_while(|c| c.is_alphanumeric() || *c == '_')
414 .collect();
415
416 let col_part = &line[add_pos + 5..];
417 let col: String = col_part.trim()
418 .chars()
419 .take_while(|c| c.is_alphanumeric() || *c == '_')
420 .collect();
421
422 if table.is_empty() || col.is_empty() {
423 None
424 } else {
425 Some((table.to_lowercase(), col.to_lowercase()))
426 }
427}
428
429fn extract_drop_table_name(line: &str) -> Option<String> {
431 let line_upper = line.to_uppercase();
432 let rest = line_upper.strip_prefix("DROP TABLE")?;
433 let rest = rest.trim_start();
434 let rest = if rest.starts_with("IF EXISTS") {
435 rest.strip_prefix("IF EXISTS")?.trim_start()
436 } else {
437 rest
438 };
439
440 let name: String = line[line.len() - rest.len()..]
442 .chars()
443 .take_while(|c| c.is_alphanumeric() || *c == '_')
444 .collect();
445
446 if name.is_empty() { None } else { Some(name.to_lowercase()) }
447}
448
449fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
451 let line_upper = line.to_uppercase();
452 let alter_pos = line_upper.find("ALTER TABLE")?;
453 let drop_pos = line_upper.find("DROP COLUMN")?;
454
455 let table_part = &line[alter_pos + 11..drop_pos];
457 let table: String = table_part.trim()
458 .chars()
459 .take_while(|c| c.is_alphanumeric() || *c == '_')
460 .collect();
461
462 let col_part = &line[drop_pos + 11..];
464 let col: String = col_part.trim()
465 .chars()
466 .take_while(|c| c.is_alphanumeric() || *c == '_')
467 .collect();
468
469 if table.is_empty() || col.is_empty() {
470 None
471 } else {
472 Some((table.to_lowercase(), col.to_lowercase()))
473 }
474}
475
476fn extract_alter_drop(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 ")?;
481
482 let table_part = &line[alter_pos + 11..drop_pos];
483 let table: String = table_part.trim()
484 .chars()
485 .take_while(|c| c.is_alphanumeric() || *c == '_')
486 .collect();
487
488 let col_part = &line[drop_pos + 6..];
489 let col: String = col_part.trim()
490 .chars()
491 .take_while(|c| c.is_alphanumeric() || *c == '_')
492 .collect();
493
494 if table.is_empty() || col.is_empty() {
495 None
496 } else {
497 Some((table.to_lowercase(), col.to_lowercase()))
498 }
499}
500
501impl TableSchema {
502 pub fn has_column(&self, name: &str) -> bool {
504 self.columns.contains_key(name)
505 }
506
507 pub fn column_type(&self, name: &str) -> Option<&str> {
509 self.columns.get(name).map(|s| s.as_str())
510 }
511}
512
513#[derive(Debug)]
515pub struct QailUsage {
516 pub file: String,
517 pub line: usize,
518 pub table: String,
519 pub columns: Vec<String>,
520 pub action: String,
521 pub is_cte_ref: bool,
522}
523
524pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
526 let mut usages = Vec::new();
527 scan_directory(Path::new(src_dir), &mut usages);
528 usages
529}
530
531fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
532 if let Ok(entries) = fs::read_dir(dir) {
533 for entry in entries.flatten() {
534 let path = entry.path();
535 if path.is_dir() {
536 scan_directory(&path, usages);
537 } else if path.extension().is_some_and(|e| e == "rs")
538 && let Ok(content) = fs::read_to_string(&path)
539 {
540 scan_file(&path.display().to_string(), &content, usages);
541 }
542 }
543 }
544}
545
546fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
547 let patterns = [
554 ("Qail::get(", "GET"),
555 ("Qail::add(", "ADD"),
556 ("Qail::del(", "DEL"),
557 ("Qail::put(", "PUT"),
558 ];
559
560 let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
563 for line in content.lines() {
564 let line = line.trim();
565 if let Some(pos) = line.find(".to_cte(") {
566 let after = &line[pos + 8..]; if let Some(name) = extract_string_arg(after) {
568 cte_names.insert(name);
569 }
570 }
571 }
572
573 let lines: Vec<&str> = content.lines().collect();
575 let mut i = 0;
576
577 while i < lines.len() {
578 let line = lines[i].trim();
579
580 for (pattern, action) in &patterns {
582 if let Some(pos) = line.find(pattern) {
583 let start_line = i + 1; let after = &line[pos + pattern.len()..];
587 if let Some(table) = extract_string_arg(after) {
588 let mut full_chain = line.to_string();
590 let mut j = i + 1;
591 while j < lines.len() {
592 let next = lines[j].trim();
593 if next.starts_with('.') {
594 full_chain.push_str(next);
595 j += 1;
596 } else if next.is_empty() {
597 j += 1; } else {
599 break;
600 }
601 }
602
603 let is_cte_ref = cte_names.contains(&table);
605
606 let columns = extract_columns(&full_chain);
608
609 usages.push(QailUsage {
610 file: file.to_string(),
611 line: start_line,
612 table,
613 columns,
614 action: action.to_string(),
615 is_cte_ref,
616 });
617
618 i = j.saturating_sub(1);
620 }
621 break; }
623 }
624 i += 1;
625 }
626}
627
628fn extract_string_arg(s: &str) -> Option<String> {
629 let s = s.trim();
631 if let Some(stripped) = s.strip_prefix('"') {
632 let end = stripped.find('"')?;
633 Some(stripped[..end].to_string())
634 } else {
635 None
636 }
637}
638
639fn extract_columns(line: &str) -> Vec<String> {
640 let mut columns = Vec::new();
641 let mut remaining = line;
642
643 while let Some(pos) = remaining.find(".column(") {
645 let after = &remaining[pos + 8..];
646 if let Some(col) = extract_string_arg(after) {
647 columns.push(col);
648 }
649 remaining = after;
650 }
651
652 remaining = line;
654
655 while let Some(pos) = remaining.find(".filter(") {
657 let after = &remaining[pos + 8..];
658 if let Some(col) = extract_string_arg(after)
659 && !col.contains('.') {
660 columns.push(col);
661 }
662 remaining = after;
663 }
664
665 for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
667 let mut temp = line;
668 while let Some(pos) = temp.find(method) {
669 let after = &temp[pos + method.len()..];
670 if let Some(col) = extract_string_arg(after)
671 && !col.contains('.') {
672 columns.push(col);
673 }
674 temp = after;
675 }
676 }
677
678 let mut remaining = line;
680 while let Some(pos) = remaining.find(".order_by(") {
681 let after = &remaining[pos + 10..];
682 if let Some(col) = extract_string_arg(after)
683 && !col.contains('.') {
684 columns.push(col);
685 }
686 remaining = after;
687 }
688
689 columns
690}
691
692pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
695 use crate::validator::Validator;
696
697 let mut validator = Validator::new();
699 for (table_name, table_schema) in &schema.tables {
700 let cols_with_types: Vec<(&str, &str)> = table_schema.columns
702 .iter()
703 .map(|(name, typ)| (name.as_str(), typ.as_str()))
704 .collect();
705 validator.add_table_with_types(table_name, &cols_with_types);
706 }
707
708 let mut errors = Vec::new();
709
710 for usage in usages {
711 if usage.is_cte_ref {
713 continue;
714 }
715
716 match validator.validate_table(&usage.table) {
718 Ok(()) => {
719 for col in &usage.columns {
721 if col.contains('.') {
723 continue;
724 }
725
726 if let Err(e) = validator.validate_column(&usage.table, col) {
727 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
728 }
729 }
730 }
731 Err(e) => {
732 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
733 }
734 }
735 }
736
737 errors
738}
739
740pub fn validate() {
742 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
743 if Path::new("schema.qail").exists() {
744 "schema".to_string()
745 } else {
746 "false".to_string()
747 }
748 });
749
750 match mode.as_str() {
751 "schema" => {
752 println!("cargo:rerun-if-changed=schema.qail");
753 println!("cargo:rerun-if-changed=migrations");
754 println!("cargo:rerun-if-env-changed=QAIL");
755
756 match Schema::parse_file("schema.qail") {
757 Ok(mut schema) => {
758 let merged = schema.merge_migrations("migrations").unwrap_or(0);
760 if merged > 0 {
761 println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
762 }
763
764 let usages = scan_source_files("src/");
765 let errors = validate_against_schema(&schema, &usages);
766
767 if errors.is_empty() {
768 println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
769 } else {
770 for error in &errors {
771 println!("cargo:warning=QAIL ERROR: {}", error);
772 }
773 panic!("QAIL validation failed with {} errors", errors.len());
775 }
776 }
777 Err(e) => {
778 println!("cargo:warning=QAIL: {}", e);
779 }
780 }
781 }
782 "live" => {
783 println!("cargo:rerun-if-env-changed=QAIL");
784 println!("cargo:rerun-if-env-changed=DATABASE_URL");
785
786 let db_url = match std::env::var("DATABASE_URL") {
788 Ok(url) => url,
789 Err(_) => {
790 panic!("QAIL=live requires DATABASE_URL environment variable");
791 }
792 };
793
794 println!("cargo:warning=QAIL: Pulling schema from live database...");
796
797 let pull_result = std::process::Command::new("qail")
798 .args(["pull", &db_url])
799 .output();
800
801 match pull_result {
802 Ok(output) => {
803 if !output.status.success() {
804 let stderr = String::from_utf8_lossy(&output.stderr);
805 panic!("QAIL: Failed to pull schema: {}", stderr);
806 }
807 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
808 }
809 Err(e) => {
810 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
812
813 let cargo_result = std::process::Command::new("cargo")
814 .args(["run", "-p", "qail", "--", "pull", &db_url])
815 .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
816 .output();
817
818 match cargo_result {
819 Ok(output) if output.status.success() => {
820 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
821 }
822 _ => {
823 panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
824 }
825 }
826 }
827 }
828
829 match Schema::parse_file("schema.qail") {
831 Ok(mut schema) => {
832 let merged = schema.merge_migrations("migrations").unwrap_or(0);
834 if merged > 0 {
835 println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
836 }
837
838 let usages = scan_source_files("src/");
839 let errors = validate_against_schema(&schema, &usages);
840
841 if errors.is_empty() {
842 println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
843 } else {
844 for error in &errors {
845 println!("cargo:warning=QAIL ERROR: {}", error);
846 }
847 panic!("QAIL validation failed with {} errors", errors.len());
848 }
849 }
850 Err(e) => {
851 panic!("QAIL: Failed to parse schema after pull: {}", e);
852 }
853 }
854 }
855 "false" | "off" | "0" => {
856 println!("cargo:rerun-if-env-changed=QAIL");
857 }
859 _ => {
860 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
861 }
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 #[test]
870 fn test_parse_schema() {
871 let content = r#"
873# Test schema
874
875table users {
876 id UUID primary_key
877 name TEXT not_null
878 email TEXT unique
879}
880
881table posts {
882 id UUID
883 user_id UUID
884 title TEXT
885}
886"#;
887 let schema = Schema::parse(content).unwrap();
888 assert!(schema.has_table("users"));
889 assert!(schema.has_table("posts"));
890 assert!(schema.table("users").unwrap().has_column("id"));
891 assert!(schema.table("users").unwrap().has_column("name"));
892 assert!(!schema.table("users").unwrap().has_column("foo"));
893 }
894
895 #[test]
896 fn test_extract_string_arg() {
897 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
898 assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
899 }
900
901 #[test]
902 fn test_scan_file() {
903 let content = r#"
905let query = Qail::get("users").column("id").column("name").eq("active", true);
906"#;
907 let mut usages = Vec::new();
908 scan_file("test.rs", content, &mut usages);
909
910 assert_eq!(usages.len(), 1);
911 assert_eq!(usages[0].table, "users");
912 assert_eq!(usages[0].action, "GET");
913 assert!(usages[0].columns.contains(&"id".to_string()));
914 assert!(usages[0].columns.contains(&"name".to_string()));
915 }
916
917 #[test]
918 fn test_scan_file_multiline() {
919 let content = r#"
921let query = Qail::get("posts")
922 .column("id")
923 .column("title")
924 .column("author")
925 .eq("published", true)
926 .order_by("created_at", Desc);
927"#;
928 let mut usages = Vec::new();
929 scan_file("test.rs", content, &mut usages);
930
931 assert_eq!(usages.len(), 1);
932 assert_eq!(usages[0].table, "posts");
933 assert_eq!(usages[0].action, "GET");
934 assert!(usages[0].columns.contains(&"id".to_string()));
935 assert!(usages[0].columns.contains(&"title".to_string()));
936 assert!(usages[0].columns.contains(&"author".to_string()));
937 }
938}
939
940fn qail_type_to_rust(qail_type: &str) -> &'static str {
946 match qail_type.to_uppercase().as_str() {
947 "UUID" => "uuid::Uuid",
948 "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
949 "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
950 "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
951 "SMALLINT" | "INT2" => "i16",
952 "FLOAT" | "FLOAT4" | "REAL" => "f32",
953 "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
954 "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
955 "BOOL" | "BOOLEAN" => "bool",
956 "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
957 "DATE" => "chrono::NaiveDate",
958 "TIME" | "TIMETZ" => "chrono::NaiveTime",
959 "JSON" | "JSONB" => "serde_json::Value",
960 "BYTEA" | "BLOB" => "Vec<u8>",
961 _ => "String", }
963}
964
965fn to_rust_ident(name: &str) -> String {
967 let name = match name {
969 "type" => "r#type",
970 "match" => "r#match",
971 "ref" => "r#ref",
972 "self" => "r#self",
973 "mod" => "r#mod",
974 "use" => "r#use",
975 _ => name,
976 };
977 name.to_string()
978}
979
980fn to_struct_name(name: &str) -> String {
982 name.chars()
983 .next()
984 .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
985 .unwrap_or_default()
986}
987
988pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1004 let schema = Schema::parse_file(schema_path)?;
1005 let code = generate_schema_code(&schema);
1006
1007 fs::write(output_path, code)
1008 .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1009
1010 Ok(())
1011}
1012
1013pub fn generate_schema_code(schema: &Schema) -> String {
1015 let mut code = String::new();
1016
1017 code.push_str("//! Auto-generated typed schema from schema.qail\n");
1019 code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1020 code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1021 code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1022
1023 let mut tables: Vec<_> = schema.tables.values().collect();
1025 tables.sort_by(|a, b| a.name.cmp(&b.name));
1026
1027 for table in &tables {
1028 let mod_name = to_rust_ident(&table.name);
1029 let struct_name = to_struct_name(&table.name);
1030
1031 code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1032 code.push_str(&format!("pub mod {} {{\n", mod_name));
1033 code.push_str(" use super::*;\n\n");
1034
1035 code.push_str(&format!(" /// Table marker for `{}`\n", table.name));
1037 code.push_str(" #[derive(Debug, Clone, Copy)]\n");
1038 code.push_str(&format!(" pub struct {};\n\n", struct_name));
1039
1040 code.push_str(&format!(" impl Table for {} {{\n", struct_name));
1041 code.push_str(&format!(" fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1042 code.push_str(" }\n\n");
1043
1044 code.push_str(&format!(" impl From<{}> for String {{\n", struct_name));
1045 code.push_str(&format!(" fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1046 code.push_str(" }\n\n");
1047
1048 code.push_str(&format!(" impl AsRef<str> for {} {{\n", struct_name));
1049 code.push_str(&format!(" fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1050 code.push_str(" }\n\n");
1051
1052 code.push_str(&format!(" /// The `{}` table\n", table.name));
1054 code.push_str(&format!(" pub const table: {} = {};\n\n", struct_name, struct_name));
1055
1056 let mut columns: Vec<_> = table.columns.iter().collect();
1058 columns.sort_by(|a, b| a.0.cmp(b.0));
1059
1060 for (col_name, col_type) in columns {
1062 let rust_type = qail_type_to_rust(col_type);
1063 let col_ident = to_rust_ident(col_name);
1064 let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1065 let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1066
1067 code.push_str(&format!(" /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1068 code.push_str(&format!(
1069 " pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1070 col_ident, rust_type, rust_policy, table.name, col_name
1071 ));
1072 }
1073
1074 code.push_str("}\n\n");
1075 }
1076
1077 code.push_str("// =============================================================================\n");
1082 code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1083 code.push_str("// =============================================================================\n\n");
1084
1085 for table in &tables {
1086 for fk in &table.foreign_keys {
1087 let from_mod = to_rust_ident(&table.name);
1092 let from_struct = to_struct_name(&table.name);
1093 let to_mod = to_rust_ident(&fk.ref_table);
1094 let to_struct = to_struct_name(&fk.ref_table);
1095
1096 code.push_str(&format!(
1099 "/// {} has a foreign key to {} via {}.{}\n",
1100 table.name, fk.ref_table, table.name, fk.column
1101 ));
1102 code.push_str(&format!(
1103 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1104 to_mod, to_struct, from_mod, from_struct
1105 ));
1106 code.push_str(&format!(
1107 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1108 fk.column, fk.ref_column
1109 ));
1110 code.push_str("}\n\n");
1111
1112 code.push_str(&format!(
1116 "/// {} is referenced by {} via {}.{}\n",
1117 fk.ref_table, table.name, table.name, fk.column
1118 ));
1119 code.push_str(&format!(
1120 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1121 from_mod, from_struct, to_mod, to_struct
1122 ));
1123 code.push_str(&format!(
1124 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1125 fk.ref_column, fk.column
1126 ));
1127 code.push_str("}\n\n");
1128 }
1129 }
1130
1131 code
1132}
1133
1134#[cfg(test)]
1135mod codegen_tests {
1136 use super::*;
1137
1138 #[test]
1139 fn test_generate_schema_code() {
1140 let schema_content = r#"
1141table users {
1142 id UUID primary_key
1143 email TEXT not_null
1144 age INT
1145}
1146
1147table posts {
1148 id UUID primary_key
1149 user_id UUID ref:users.id
1150 title TEXT
1151}
1152"#;
1153
1154 let schema = Schema::parse(schema_content).unwrap();
1155 let code = generate_schema_code(&schema);
1156
1157 assert!(code.contains("pub mod users {"));
1159 assert!(code.contains("pub mod posts {"));
1160
1161 assert!(code.contains("pub struct Users;"));
1163 assert!(code.contains("pub struct Posts;"));
1164
1165 assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1167 assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1168 assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1169
1170 assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1172 assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1173 }
1174
1175 #[test]
1176 fn test_generate_protected_column() {
1177 let schema_content = r#"
1178table secrets {
1179 id UUID primary_key
1180 token TEXT protected
1181}
1182"#;
1183 let schema = Schema::parse(schema_content).unwrap();
1184 let code = generate_schema_code(&schema);
1185
1186 assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1188 }
1189}
1190
1191
1192
1193#[cfg(test)]
1194mod migration_parser_tests {
1195 use super::*;
1196
1197 #[test]
1198 fn test_agent_contracts_migration_parses_all_columns() {
1199 let sql = r#"
1200CREATE TABLE agent_contracts (
1201 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1202 agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1203 operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1204 pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1205 commission_percent DECIMAL(5,2),
1206 static_markup DECIMAL(10,2),
1207 is_active BOOLEAN DEFAULT true,
1208 valid_from DATE,
1209 valid_until DATE,
1210 approved_by UUID REFERENCES users(id),
1211 created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1212 updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1213 UNIQUE(agent_id, operator_id)
1214);
1215"#;
1216
1217 let mut schema = Schema::default();
1218 schema.parse_sql_migration(sql);
1219
1220 let table = schema.tables.get("agent_contracts")
1221 .expect("agent_contracts table should exist");
1222
1223 for col in &["id", "agent_id", "operator_id", "pricing_model",
1224 "commission_percent", "static_markup", "is_active",
1225 "valid_from", "valid_until", "approved_by",
1226 "created_at", "updated_at"] {
1227 assert!(
1228 table.columns.contains_key(*col),
1229 "Missing column: '{}'. Found: {:?}",
1230 col, table.columns.keys().collect::<Vec<_>>()
1231 );
1232 }
1233 }
1234
1235 #[test]
1238 fn test_keyword_prefixed_column_names_are_not_skipped() {
1239 let sql = r#"
1240CREATE TABLE edge_cases (
1241 id UUID PRIMARY KEY,
1242 created_at TIMESTAMPTZ NOT NULL,
1243 created_by UUID,
1244 primary_contact VARCHAR(255),
1245 check_status VARCHAR(20),
1246 unique_code VARCHAR(50),
1247 foreign_ref UUID,
1248 constraint_name VARCHAR(100),
1249 PRIMARY KEY (id),
1250 CHECK (check_status IN ('pending', 'active')),
1251 UNIQUE (unique_code),
1252 CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1253);
1254"#;
1255
1256 let mut schema = Schema::default();
1257 schema.parse_sql_migration(sql);
1258
1259 let table = schema.tables.get("edge_cases")
1260 .expect("edge_cases table should exist");
1261
1262 for col in &["created_at", "created_by", "primary_contact",
1264 "check_status", "unique_code", "foreign_ref",
1265 "constraint_name"] {
1266 assert!(
1267 table.columns.contains_key(*col),
1268 "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1269 col, table.columns.keys().collect::<Vec<_>>()
1270 );
1271 }
1272
1273 assert!(!table.columns.contains_key("primary"),
1276 "Constraint keyword 'PRIMARY' should not be treated as a column");
1277 }
1278}