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();
346 if line_upper.starts_with("CREATE") ||
347 line_upper.starts_with("PRIMARY") ||
348 line_upper.starts_with("FOREIGN") ||
349 line_upper.starts_with("UNIQUE") ||
350 line_upper.starts_with("CHECK") ||
351 line_upper.starts_with("CONSTRAINT") ||
352 line_upper.starts_with(")") ||
353 line_upper.starts_with("(") ||
354 line.is_empty() {
355 return None;
356 }
357
358 let name: String = line
360 .trim_start_matches('(')
361 .trim()
362 .chars()
363 .take_while(|c| c.is_alphanumeric() || *c == '_')
364 .collect();
365
366 if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
367}
368
369fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
371 let line_upper = line.to_uppercase();
372 let alter_pos = line_upper.find("ALTER TABLE")?;
373 let add_pos = line_upper.find("ADD COLUMN")?;
374
375 let table_part = &line[alter_pos + 11..add_pos];
377 let table: String = table_part.trim()
378 .chars()
379 .take_while(|c| c.is_alphanumeric() || *c == '_')
380 .collect();
381
382 let col_part = &line[add_pos + 10..];
384 let col: String = col_part.trim()
385 .chars()
386 .take_while(|c| c.is_alphanumeric() || *c == '_')
387 .collect();
388
389 if table.is_empty() || col.is_empty() {
390 None
391 } else {
392 Some((table.to_lowercase(), col.to_lowercase()))
393 }
394}
395
396fn extract_alter_add(line: &str) -> Option<(String, String)> {
398 let line_upper = line.to_uppercase();
399 let alter_pos = line_upper.find("ALTER TABLE")?;
400 let add_pos = line_upper.find(" ADD ")?;
401
402 let table_part = &line[alter_pos + 11..add_pos];
403 let table: String = table_part.trim()
404 .chars()
405 .take_while(|c| c.is_alphanumeric() || *c == '_')
406 .collect();
407
408 let col_part = &line[add_pos + 5..];
409 let col: String = col_part.trim()
410 .chars()
411 .take_while(|c| c.is_alphanumeric() || *c == '_')
412 .collect();
413
414 if table.is_empty() || col.is_empty() {
415 None
416 } else {
417 Some((table.to_lowercase(), col.to_lowercase()))
418 }
419}
420
421fn extract_drop_table_name(line: &str) -> Option<String> {
423 let line_upper = line.to_uppercase();
424 let rest = line_upper.strip_prefix("DROP TABLE")?;
425 let rest = rest.trim_start();
426 let rest = if rest.starts_with("IF EXISTS") {
427 rest.strip_prefix("IF EXISTS")?.trim_start()
428 } else {
429 rest
430 };
431
432 let name: String = line[line.len() - rest.len()..]
434 .chars()
435 .take_while(|c| c.is_alphanumeric() || *c == '_')
436 .collect();
437
438 if name.is_empty() { None } else { Some(name.to_lowercase()) }
439}
440
441fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
443 let line_upper = line.to_uppercase();
444 let alter_pos = line_upper.find("ALTER TABLE")?;
445 let drop_pos = line_upper.find("DROP COLUMN")?;
446
447 let table_part = &line[alter_pos + 11..drop_pos];
449 let table: String = table_part.trim()
450 .chars()
451 .take_while(|c| c.is_alphanumeric() || *c == '_')
452 .collect();
453
454 let col_part = &line[drop_pos + 11..];
456 let col: String = col_part.trim()
457 .chars()
458 .take_while(|c| c.is_alphanumeric() || *c == '_')
459 .collect();
460
461 if table.is_empty() || col.is_empty() {
462 None
463 } else {
464 Some((table.to_lowercase(), col.to_lowercase()))
465 }
466}
467
468fn extract_alter_drop(line: &str) -> Option<(String, String)> {
470 let line_upper = line.to_uppercase();
471 let alter_pos = line_upper.find("ALTER TABLE")?;
472 let drop_pos = line_upper.find(" DROP ")?;
473
474 let table_part = &line[alter_pos + 11..drop_pos];
475 let table: String = table_part.trim()
476 .chars()
477 .take_while(|c| c.is_alphanumeric() || *c == '_')
478 .collect();
479
480 let col_part = &line[drop_pos + 6..];
481 let col: String = col_part.trim()
482 .chars()
483 .take_while(|c| c.is_alphanumeric() || *c == '_')
484 .collect();
485
486 if table.is_empty() || col.is_empty() {
487 None
488 } else {
489 Some((table.to_lowercase(), col.to_lowercase()))
490 }
491}
492
493impl TableSchema {
494 pub fn has_column(&self, name: &str) -> bool {
496 self.columns.contains_key(name)
497 }
498
499 pub fn column_type(&self, name: &str) -> Option<&str> {
501 self.columns.get(name).map(|s| s.as_str())
502 }
503}
504
505#[derive(Debug)]
507pub struct QailUsage {
508 pub file: String,
509 pub line: usize,
510 pub table: String,
511 pub columns: Vec<String>,
512 pub action: String,
513 pub is_cte_ref: bool,
514}
515
516pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
518 let mut usages = Vec::new();
519 scan_directory(Path::new(src_dir), &mut usages);
520 usages
521}
522
523fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
524 if let Ok(entries) = fs::read_dir(dir) {
525 for entry in entries.flatten() {
526 let path = entry.path();
527 if path.is_dir() {
528 scan_directory(&path, usages);
529 } else if path.extension().is_some_and(|e| e == "rs")
530 && let Ok(content) = fs::read_to_string(&path)
531 {
532 scan_file(&path.display().to_string(), &content, usages);
533 }
534 }
535 }
536}
537
538fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
539 let patterns = [
546 ("Qail::get(", "GET"),
547 ("Qail::add(", "ADD"),
548 ("Qail::del(", "DEL"),
549 ("Qail::put(", "PUT"),
550 ];
551
552 let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
555 for line in content.lines() {
556 let line = line.trim();
557 if let Some(pos) = line.find(".to_cte(") {
558 let after = &line[pos + 8..]; if let Some(name) = extract_string_arg(after) {
560 cte_names.insert(name);
561 }
562 }
563 }
564
565 let lines: Vec<&str> = content.lines().collect();
567 let mut i = 0;
568
569 while i < lines.len() {
570 let line = lines[i].trim();
571
572 for (pattern, action) in &patterns {
574 if let Some(pos) = line.find(pattern) {
575 let start_line = i + 1; let after = &line[pos + pattern.len()..];
579 if let Some(table) = extract_string_arg(after) {
580 let mut full_chain = line.to_string();
582 let mut j = i + 1;
583 while j < lines.len() {
584 let next = lines[j].trim();
585 if next.starts_with('.') {
586 full_chain.push_str(next);
587 j += 1;
588 } else if next.is_empty() {
589 j += 1; } else {
591 break;
592 }
593 }
594
595 let is_cte_ref = cte_names.contains(&table);
597
598 let columns = extract_columns(&full_chain);
600
601 usages.push(QailUsage {
602 file: file.to_string(),
603 line: start_line,
604 table,
605 columns,
606 action: action.to_string(),
607 is_cte_ref,
608 });
609
610 i = j.saturating_sub(1);
612 }
613 break; }
615 }
616 i += 1;
617 }
618}
619
620fn extract_string_arg(s: &str) -> Option<String> {
621 let s = s.trim();
623 if let Some(stripped) = s.strip_prefix('"') {
624 let end = stripped.find('"')?;
625 Some(stripped[..end].to_string())
626 } else {
627 None
628 }
629}
630
631fn extract_columns(line: &str) -> Vec<String> {
632 let mut columns = Vec::new();
633 let mut remaining = line;
634
635 while let Some(pos) = remaining.find(".column(") {
637 let after = &remaining[pos + 8..];
638 if let Some(col) = extract_string_arg(after) {
639 columns.push(col);
640 }
641 remaining = after;
642 }
643
644 remaining = line;
646
647 while let Some(pos) = remaining.find(".filter(") {
649 let after = &remaining[pos + 8..];
650 if let Some(col) = extract_string_arg(after)
651 && !col.contains('.') {
652 columns.push(col);
653 }
654 remaining = after;
655 }
656
657 for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
659 let mut temp = line;
660 while let Some(pos) = temp.find(method) {
661 let after = &temp[pos + method.len()..];
662 if let Some(col) = extract_string_arg(after)
663 && !col.contains('.') {
664 columns.push(col);
665 }
666 temp = after;
667 }
668 }
669
670 let mut remaining = line;
672 while let Some(pos) = remaining.find(".order_by(") {
673 let after = &remaining[pos + 10..];
674 if let Some(col) = extract_string_arg(after)
675 && !col.contains('.') {
676 columns.push(col);
677 }
678 remaining = after;
679 }
680
681 columns
682}
683
684pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
687 use crate::validator::Validator;
688
689 let mut validator = Validator::new();
691 for (table_name, table_schema) in &schema.tables {
692 let cols_with_types: Vec<(&str, &str)> = table_schema.columns
694 .iter()
695 .map(|(name, typ)| (name.as_str(), typ.as_str()))
696 .collect();
697 validator.add_table_with_types(table_name, &cols_with_types);
698 }
699
700 let mut errors = Vec::new();
701
702 for usage in usages {
703 if usage.is_cte_ref {
705 continue;
706 }
707
708 match validator.validate_table(&usage.table) {
710 Ok(()) => {
711 for col in &usage.columns {
713 if col.contains('.') {
715 continue;
716 }
717
718 if let Err(e) = validator.validate_column(&usage.table, col) {
719 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
720 }
721 }
722 }
723 Err(e) => {
724 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
725 }
726 }
727 }
728
729 errors
730}
731
732pub fn validate() {
734 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
735 if Path::new("schema.qail").exists() {
736 "schema".to_string()
737 } else {
738 "false".to_string()
739 }
740 });
741
742 match mode.as_str() {
743 "schema" => {
744 println!("cargo:rerun-if-changed=schema.qail");
745 println!("cargo:rerun-if-changed=migrations");
746 println!("cargo:rerun-if-env-changed=QAIL");
747
748 match Schema::parse_file("schema.qail") {
749 Ok(mut schema) => {
750 let merged = schema.merge_migrations("migrations").unwrap_or(0);
752 if merged > 0 {
753 println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
754 }
755
756 let usages = scan_source_files("src/");
757 let errors = validate_against_schema(&schema, &usages);
758
759 if errors.is_empty() {
760 println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
761 } else {
762 for error in &errors {
763 println!("cargo:warning=QAIL ERROR: {}", error);
764 }
765 panic!("QAIL validation failed with {} errors", errors.len());
767 }
768 }
769 Err(e) => {
770 println!("cargo:warning=QAIL: {}", e);
771 }
772 }
773 }
774 "live" => {
775 println!("cargo:rerun-if-env-changed=QAIL");
776 println!("cargo:rerun-if-env-changed=DATABASE_URL");
777
778 let db_url = match std::env::var("DATABASE_URL") {
780 Ok(url) => url,
781 Err(_) => {
782 panic!("QAIL=live requires DATABASE_URL environment variable");
783 }
784 };
785
786 println!("cargo:warning=QAIL: Pulling schema from live database...");
788
789 let pull_result = std::process::Command::new("qail")
790 .args(["pull", &db_url])
791 .output();
792
793 match pull_result {
794 Ok(output) => {
795 if !output.status.success() {
796 let stderr = String::from_utf8_lossy(&output.stderr);
797 panic!("QAIL: Failed to pull schema: {}", stderr);
798 }
799 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
800 }
801 Err(e) => {
802 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
804
805 let cargo_result = std::process::Command::new("cargo")
806 .args(["run", "-p", "qail", "--", "pull", &db_url])
807 .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
808 .output();
809
810 match cargo_result {
811 Ok(output) if output.status.success() => {
812 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
813 }
814 _ => {
815 panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
816 }
817 }
818 }
819 }
820
821 match Schema::parse_file("schema.qail") {
823 Ok(mut schema) => {
824 let merged = schema.merge_migrations("migrations").unwrap_or(0);
826 if merged > 0 {
827 println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
828 }
829
830 let usages = scan_source_files("src/");
831 let errors = validate_against_schema(&schema, &usages);
832
833 if errors.is_empty() {
834 println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
835 } else {
836 for error in &errors {
837 println!("cargo:warning=QAIL ERROR: {}", error);
838 }
839 panic!("QAIL validation failed with {} errors", errors.len());
840 }
841 }
842 Err(e) => {
843 panic!("QAIL: Failed to parse schema after pull: {}", e);
844 }
845 }
846 }
847 "false" | "off" | "0" => {
848 println!("cargo:rerun-if-env-changed=QAIL");
849 }
851 _ => {
852 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
853 }
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn test_parse_schema() {
863 let content = r#"
865# Test schema
866
867table users {
868 id UUID primary_key
869 name TEXT not_null
870 email TEXT unique
871}
872
873table posts {
874 id UUID
875 user_id UUID
876 title TEXT
877}
878"#;
879 let schema = Schema::parse(content).unwrap();
880 assert!(schema.has_table("users"));
881 assert!(schema.has_table("posts"));
882 assert!(schema.table("users").unwrap().has_column("id"));
883 assert!(schema.table("users").unwrap().has_column("name"));
884 assert!(!schema.table("users").unwrap().has_column("foo"));
885 }
886
887 #[test]
888 fn test_extract_string_arg() {
889 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
890 assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
891 }
892
893 #[test]
894 fn test_scan_file() {
895 let content = r#"
897let query = Qail::get("users").column("id").column("name").eq("active", true);
898"#;
899 let mut usages = Vec::new();
900 scan_file("test.rs", content, &mut usages);
901
902 assert_eq!(usages.len(), 1);
903 assert_eq!(usages[0].table, "users");
904 assert_eq!(usages[0].action, "GET");
905 assert!(usages[0].columns.contains(&"id".to_string()));
906 assert!(usages[0].columns.contains(&"name".to_string()));
907 }
908
909 #[test]
910 fn test_scan_file_multiline() {
911 let content = r#"
913let query = Qail::get("posts")
914 .column("id")
915 .column("title")
916 .column("author")
917 .eq("published", true)
918 .order_by("created_at", Desc);
919"#;
920 let mut usages = Vec::new();
921 scan_file("test.rs", content, &mut usages);
922
923 assert_eq!(usages.len(), 1);
924 assert_eq!(usages[0].table, "posts");
925 assert_eq!(usages[0].action, "GET");
926 assert!(usages[0].columns.contains(&"id".to_string()));
927 assert!(usages[0].columns.contains(&"title".to_string()));
928 assert!(usages[0].columns.contains(&"author".to_string()));
929 }
930}
931
932fn qail_type_to_rust(qail_type: &str) -> &'static str {
938 match qail_type.to_uppercase().as_str() {
939 "UUID" => "uuid::Uuid",
940 "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
941 "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
942 "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
943 "SMALLINT" | "INT2" => "i16",
944 "FLOAT" | "FLOAT4" | "REAL" => "f32",
945 "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
946 "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
947 "BOOL" | "BOOLEAN" => "bool",
948 "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
949 "DATE" => "chrono::NaiveDate",
950 "TIME" | "TIMETZ" => "chrono::NaiveTime",
951 "JSON" | "JSONB" => "serde_json::Value",
952 "BYTEA" | "BLOB" => "Vec<u8>",
953 _ => "String", }
955}
956
957fn to_rust_ident(name: &str) -> String {
959 let name = match name {
961 "type" => "r#type",
962 "match" => "r#match",
963 "ref" => "r#ref",
964 "self" => "r#self",
965 "mod" => "r#mod",
966 "use" => "r#use",
967 _ => name,
968 };
969 name.to_string()
970}
971
972fn to_struct_name(name: &str) -> String {
974 name.chars()
975 .next()
976 .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
977 .unwrap_or_default()
978}
979
980pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
996 let schema = Schema::parse_file(schema_path)?;
997 let code = generate_schema_code(&schema);
998
999 fs::write(output_path, code)
1000 .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1001
1002 Ok(())
1003}
1004
1005pub fn generate_schema_code(schema: &Schema) -> String {
1007 let mut code = String::new();
1008
1009 code.push_str("//! Auto-generated typed schema from schema.qail\n");
1011 code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1012 code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1013 code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1014
1015 let mut tables: Vec<_> = schema.tables.values().collect();
1017 tables.sort_by(|a, b| a.name.cmp(&b.name));
1018
1019 for table in &tables {
1020 let mod_name = to_rust_ident(&table.name);
1021 let struct_name = to_struct_name(&table.name);
1022
1023 code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1024 code.push_str(&format!("pub mod {} {{\n", mod_name));
1025 code.push_str(" use super::*;\n\n");
1026
1027 code.push_str(&format!(" /// Table marker for `{}`\n", table.name));
1029 code.push_str(" #[derive(Debug, Clone, Copy)]\n");
1030 code.push_str(&format!(" pub struct {};\n\n", struct_name));
1031
1032 code.push_str(&format!(" impl Table for {} {{\n", struct_name));
1033 code.push_str(&format!(" fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1034 code.push_str(" }\n\n");
1035
1036 code.push_str(&format!(" impl From<{}> for String {{\n", struct_name));
1037 code.push_str(&format!(" fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1038 code.push_str(" }\n\n");
1039
1040 code.push_str(&format!(" impl AsRef<str> for {} {{\n", struct_name));
1041 code.push_str(&format!(" fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1042 code.push_str(" }\n\n");
1043
1044 code.push_str(&format!(" /// The `{}` table\n", table.name));
1046 code.push_str(&format!(" pub const table: {} = {};\n\n", struct_name, struct_name));
1047
1048 let mut columns: Vec<_> = table.columns.iter().collect();
1050 columns.sort_by(|a, b| a.0.cmp(b.0));
1051
1052 for (col_name, col_type) in columns {
1054 let rust_type = qail_type_to_rust(col_type);
1055 let col_ident = to_rust_ident(col_name);
1056 let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1057 let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1058
1059 code.push_str(&format!(" /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1060 code.push_str(&format!(
1061 " pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1062 col_ident, rust_type, rust_policy, table.name, col_name
1063 ));
1064 }
1065
1066 code.push_str("}\n\n");
1067 }
1068
1069 code.push_str("// =============================================================================\n");
1074 code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1075 code.push_str("// =============================================================================\n\n");
1076
1077 for table in &tables {
1078 for fk in &table.foreign_keys {
1079 let from_mod = to_rust_ident(&table.name);
1084 let from_struct = to_struct_name(&table.name);
1085 let to_mod = to_rust_ident(&fk.ref_table);
1086 let to_struct = to_struct_name(&fk.ref_table);
1087
1088 code.push_str(&format!(
1091 "/// {} has a foreign key to {} via {}.{}\n",
1092 table.name, fk.ref_table, table.name, fk.column
1093 ));
1094 code.push_str(&format!(
1095 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1096 to_mod, to_struct, from_mod, from_struct
1097 ));
1098 code.push_str(&format!(
1099 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1100 fk.column, fk.ref_column
1101 ));
1102 code.push_str("}\n\n");
1103
1104 code.push_str(&format!(
1108 "/// {} is referenced by {} via {}.{}\n",
1109 fk.ref_table, table.name, table.name, fk.column
1110 ));
1111 code.push_str(&format!(
1112 "impl RelatedTo<{}::{}> for {}::{} {{\n",
1113 from_mod, from_struct, to_mod, to_struct
1114 ));
1115 code.push_str(&format!(
1116 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1117 fk.ref_column, fk.column
1118 ));
1119 code.push_str("}\n\n");
1120 }
1121 }
1122
1123 code
1124}
1125
1126#[cfg(test)]
1127mod codegen_tests {
1128 use super::*;
1129
1130 #[test]
1131 fn test_generate_schema_code() {
1132 let schema_content = r#"
1133table users {
1134 id UUID primary_key
1135 email TEXT not_null
1136 age INT
1137}
1138
1139table posts {
1140 id UUID primary_key
1141 user_id UUID ref:users.id
1142 title TEXT
1143}
1144"#;
1145
1146 let schema = Schema::parse(schema_content).unwrap();
1147 let code = generate_schema_code(&schema);
1148
1149 assert!(code.contains("pub mod users {"));
1151 assert!(code.contains("pub mod posts {"));
1152
1153 assert!(code.contains("pub struct Users;"));
1155 assert!(code.contains("pub struct Posts;"));
1156
1157 assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1159 assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1160 assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1161
1162 assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1164 assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1165 }
1166
1167 #[test]
1168 fn test_generate_protected_column() {
1169 let schema_content = r#"
1170table secrets {
1171 id UUID primary_key
1172 token TEXT protected
1173}
1174"#;
1175 let schema = Schema::parse(schema_content).unwrap();
1176 let code = generate_schema_code(&schema);
1177
1178 assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1180 }
1181}
1182