1use std::collections::HashMap;
22use std::fs;
23use std::path::Path;
24
25#[derive(Debug, Clone)]
27pub struct TableSchema {
28 pub name: String,
29 pub columns: HashMap<String, String>,
31}
32
33#[derive(Debug, Default)]
35pub struct Schema {
36 pub tables: HashMap<String, TableSchema>,
37}
38
39impl Schema {
40 pub fn parse_file(path: &str) -> Result<Self, String> {
42 let content = fs::read_to_string(path)
43 .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
44 Self::parse(&content)
45 }
46
47 pub fn parse(content: &str) -> Result<Self, String> {
49 let mut schema = Schema::default();
50 let mut current_table: Option<String> = None;
51 let mut current_columns: HashMap<String, String> = HashMap::new();
52
53 for line in content.lines() {
54 let line = line.trim();
55
56 if line.is_empty() || line.starts_with('#') {
58 continue;
59 }
60
61 if line.starts_with("table ") && line.ends_with('{') {
63 if let Some(table_name) = current_table.take() {
65 schema.tables.insert(table_name.clone(), TableSchema {
66 name: table_name,
67 columns: std::mem::take(&mut current_columns),
68 });
69 }
70
71 let name = line.trim_start_matches("table ")
73 .trim_end_matches('{')
74 .trim()
75 .to_string();
76 current_table = Some(name);
77 }
78 else if line == "}" {
80 if let Some(table_name) = current_table.take() {
81 schema.tables.insert(table_name.clone(), TableSchema {
82 name: table_name,
83 columns: std::mem::take(&mut current_columns),
84 });
85 }
86 }
87 else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
90 let mut parts = line.split_whitespace();
91 if let Some(col_name) = parts.next() {
92 let col_type = parts.next().unwrap_or("TEXT").to_uppercase();
94 current_columns.insert(col_name.to_string(), col_type);
95 }
96 }
97 }
98
99 Ok(schema)
100 }
101
102 pub fn has_table(&self, name: &str) -> bool {
104 self.tables.contains_key(name)
105 }
106
107 pub fn table(&self, name: &str) -> Option<&TableSchema> {
109 self.tables.get(name)
110 }
111
112 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
117 use std::fs;
118
119 let dir = Path::new(migrations_dir);
120 if !dir.exists() {
121 return Ok(0); }
123
124 let mut merged_count = 0;
125
126 let entries = fs::read_dir(dir)
128 .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
129
130 for entry in entries.flatten() {
131 let path = entry.path();
132
133 let up_sql = if path.is_dir() {
135 path.join("up.sql")
136 } else if path.extension().is_some_and(|e| e == "sql") {
137 path.clone()
138 } else {
139 continue;
140 };
141
142 if up_sql.exists() {
143 let content = fs::read_to_string(&up_sql)
144 .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
145
146 merged_count += self.parse_sql_migration(&content);
147 }
148 }
149
150 Ok(merged_count)
151 }
152
153 fn parse_sql_migration(&mut self, sql: &str) -> usize {
155 let mut changes = 0;
156
157 for line in sql.lines() {
160 let line_upper = line.trim().to_uppercase();
161
162 if line_upper.starts_with("CREATE TABLE") {
163 if let Some(table_name) = extract_create_table_name(line) {
164 if !self.tables.contains_key(&table_name) {
166 self.tables.insert(table_name.clone(), TableSchema {
167 name: table_name,
168 columns: HashMap::new(),
169 });
170 changes += 1;
171 }
172 }
173 }
174 }
175
176 let mut current_table: Option<String> = None;
178 let mut in_create_block = false;
179 let mut paren_depth = 0;
180
181 for line in sql.lines() {
182 let line = line.trim();
183 let line_upper = line.to_uppercase();
184
185 if line_upper.starts_with("CREATE TABLE") {
186 if let Some(name) = extract_create_table_name(line) {
187 current_table = Some(name);
188 in_create_block = true;
189 paren_depth = 0;
190 }
191 }
192
193 if in_create_block {
194 paren_depth += line.chars().filter(|c| *c == '(').count();
195 paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
196
197 if let Some(col) = extract_column_from_create(line) {
199 if let Some(ref table) = current_table {
200 if let Some(t) = self.tables.get_mut(table) {
201 if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
202 changes += 1;
203 }
204 }
205 }
206 }
207
208 if paren_depth == 0 && line.contains(')') {
209 in_create_block = false;
210 current_table = None;
211 }
212 }
213
214 if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN") {
216 if let Some((table, col)) = extract_alter_add_column(line) {
217 if let Some(t) = self.tables.get_mut(&table) {
218 if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
219 changes += 1;
220 }
221 } else {
222 let mut cols = HashMap::new();
224 cols.insert(col, "TEXT".to_string());
225 self.tables.insert(table.clone(), TableSchema {
226 name: table,
227 columns: cols,
228 });
229 changes += 1;
230 }
231 }
232 }
233
234 if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN") {
236 if let Some((table, col)) = extract_alter_add(line) {
237 if let Some(t) = self.tables.get_mut(&table) {
238 if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
239 changes += 1;
240 }
241 }
242 }
243 }
244
245 if line_upper.starts_with("DROP TABLE") {
247 if let Some(table_name) = extract_drop_table_name(line) {
248 if self.tables.remove(&table_name).is_some() {
249 changes += 1;
250 }
251 }
252 }
253
254 if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN") {
256 if let Some((table, col)) = extract_alter_drop_column(line) {
257 if let Some(t) = self.tables.get_mut(&table) {
258 if t.columns.remove(&col).is_some() {
259 changes += 1;
260 }
261 }
262 }
263 }
264
265 if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ")
267 && !line_upper.contains("DROP COLUMN")
268 && !line_upper.contains("DROP CONSTRAINT")
269 && !line_upper.contains("DROP INDEX")
270 {
271 if let Some((table, col)) = extract_alter_drop(line) {
272 if let Some(t) = self.tables.get_mut(&table) {
273 if t.columns.remove(&col).is_some() {
274 changes += 1;
275 }
276 }
277 }
278 }
279 }
280
281 changes
282 }
283}
284
285fn extract_create_table_name(line: &str) -> Option<String> {
287 let line_upper = line.to_uppercase();
288 let rest = line_upper.strip_prefix("CREATE TABLE")?;
289 let rest = rest.trim_start();
290 let rest = if rest.starts_with("IF NOT EXISTS") {
291 rest.strip_prefix("IF NOT EXISTS")?.trim_start()
292 } else {
293 rest
294 };
295
296 let name: String = line[line.len() - rest.len()..]
298 .chars()
299 .take_while(|c| c.is_alphanumeric() || *c == '_')
300 .collect();
301
302 if name.is_empty() { None } else { Some(name.to_lowercase()) }
303}
304
305fn extract_column_from_create(line: &str) -> Option<String> {
307 let line = line.trim();
308
309 let line_upper = line.to_uppercase();
311 if line_upper.starts_with("CREATE") ||
312 line_upper.starts_with("PRIMARY") ||
313 line_upper.starts_with("FOREIGN") ||
314 line_upper.starts_with("UNIQUE") ||
315 line_upper.starts_with("CHECK") ||
316 line_upper.starts_with("CONSTRAINT") ||
317 line_upper.starts_with(")") ||
318 line_upper.starts_with("(") ||
319 line.is_empty() {
320 return None;
321 }
322
323 let name: String = line
325 .trim_start_matches('(')
326 .trim()
327 .chars()
328 .take_while(|c| c.is_alphanumeric() || *c == '_')
329 .collect();
330
331 if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
332}
333
334fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
336 let line_upper = line.to_uppercase();
337 let alter_pos = line_upper.find("ALTER TABLE")?;
338 let add_pos = line_upper.find("ADD COLUMN")?;
339
340 let table_part = &line[alter_pos + 11..add_pos];
342 let table: String = table_part.trim()
343 .chars()
344 .take_while(|c| c.is_alphanumeric() || *c == '_')
345 .collect();
346
347 let col_part = &line[add_pos + 10..];
349 let col: String = col_part.trim()
350 .chars()
351 .take_while(|c| c.is_alphanumeric() || *c == '_')
352 .collect();
353
354 if table.is_empty() || col.is_empty() {
355 None
356 } else {
357 Some((table.to_lowercase(), col.to_lowercase()))
358 }
359}
360
361fn extract_alter_add(line: &str) -> Option<(String, String)> {
363 let line_upper = line.to_uppercase();
364 let alter_pos = line_upper.find("ALTER TABLE")?;
365 let add_pos = line_upper.find(" ADD ")?;
366
367 let table_part = &line[alter_pos + 11..add_pos];
368 let table: String = table_part.trim()
369 .chars()
370 .take_while(|c| c.is_alphanumeric() || *c == '_')
371 .collect();
372
373 let col_part = &line[add_pos + 5..];
374 let col: String = col_part.trim()
375 .chars()
376 .take_while(|c| c.is_alphanumeric() || *c == '_')
377 .collect();
378
379 if table.is_empty() || col.is_empty() {
380 None
381 } else {
382 Some((table.to_lowercase(), col.to_lowercase()))
383 }
384}
385
386fn extract_drop_table_name(line: &str) -> Option<String> {
388 let line_upper = line.to_uppercase();
389 let rest = line_upper.strip_prefix("DROP TABLE")?;
390 let rest = rest.trim_start();
391 let rest = if rest.starts_with("IF EXISTS") {
392 rest.strip_prefix("IF EXISTS")?.trim_start()
393 } else {
394 rest
395 };
396
397 let name: String = line[line.len() - rest.len()..]
399 .chars()
400 .take_while(|c| c.is_alphanumeric() || *c == '_')
401 .collect();
402
403 if name.is_empty() { None } else { Some(name.to_lowercase()) }
404}
405
406fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
408 let line_upper = line.to_uppercase();
409 let alter_pos = line_upper.find("ALTER TABLE")?;
410 let drop_pos = line_upper.find("DROP COLUMN")?;
411
412 let table_part = &line[alter_pos + 11..drop_pos];
414 let table: String = table_part.trim()
415 .chars()
416 .take_while(|c| c.is_alphanumeric() || *c == '_')
417 .collect();
418
419 let col_part = &line[drop_pos + 11..];
421 let col: String = col_part.trim()
422 .chars()
423 .take_while(|c| c.is_alphanumeric() || *c == '_')
424 .collect();
425
426 if table.is_empty() || col.is_empty() {
427 None
428 } else {
429 Some((table.to_lowercase(), col.to_lowercase()))
430 }
431}
432
433fn extract_alter_drop(line: &str) -> Option<(String, String)> {
435 let line_upper = line.to_uppercase();
436 let alter_pos = line_upper.find("ALTER TABLE")?;
437 let drop_pos = line_upper.find(" DROP ")?;
438
439 let table_part = &line[alter_pos + 11..drop_pos];
440 let table: String = table_part.trim()
441 .chars()
442 .take_while(|c| c.is_alphanumeric() || *c == '_')
443 .collect();
444
445 let col_part = &line[drop_pos + 6..];
446 let col: String = col_part.trim()
447 .chars()
448 .take_while(|c| c.is_alphanumeric() || *c == '_')
449 .collect();
450
451 if table.is_empty() || col.is_empty() {
452 None
453 } else {
454 Some((table.to_lowercase(), col.to_lowercase()))
455 }
456}
457
458impl TableSchema {
459 pub fn has_column(&self, name: &str) -> bool {
461 self.columns.contains_key(name)
462 }
463
464 pub fn column_type(&self, name: &str) -> Option<&str> {
466 self.columns.get(name).map(|s| s.as_str())
467 }
468}
469
470#[derive(Debug)]
472pub struct QailUsage {
473 pub file: String,
474 pub line: usize,
475 pub table: String,
476 pub columns: Vec<String>,
477 pub action: String,
478 pub is_cte_ref: bool,
479}
480
481pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
483 let mut usages = Vec::new();
484 scan_directory(Path::new(src_dir), &mut usages);
485 usages
486}
487
488fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
489 if let Ok(entries) = fs::read_dir(dir) {
490 for entry in entries.flatten() {
491 let path = entry.path();
492 if path.is_dir() {
493 scan_directory(&path, usages);
494 } else if path.extension().map_or(false, |e| e == "rs") {
495 if let Ok(content) = fs::read_to_string(&path) {
496 scan_file(&path.display().to_string(), &content, usages);
497 }
498 }
499 }
500 }
501}
502
503fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
504 let patterns = [
511 ("Qail::get(", "GET"),
512 ("Qail::add(", "ADD"),
513 ("Qail::del(", "DEL"),
514 ("Qail::put(", "PUT"),
515 ];
516
517 let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
520 for line in content.lines() {
521 let line = line.trim();
522 if let Some(pos) = line.find(".to_cte(") {
523 let after = &line[pos + 8..]; if let Some(name) = extract_string_arg(after) {
525 cte_names.insert(name);
526 }
527 }
528 }
529
530 let lines: Vec<&str> = content.lines().collect();
532 let mut i = 0;
533
534 while i < lines.len() {
535 let line = lines[i].trim();
536
537 for (pattern, action) in &patterns {
539 if let Some(pos) = line.find(pattern) {
540 let start_line = i + 1; let after = &line[pos + pattern.len()..];
544 if let Some(table) = extract_string_arg(after) {
545 let mut full_chain = line.to_string();
547 let mut j = i + 1;
548 while j < lines.len() {
549 let next = lines[j].trim();
550 if next.starts_with('.') {
551 full_chain.push_str(next);
552 j += 1;
553 } else if next.is_empty() {
554 j += 1; } else {
556 break;
557 }
558 }
559
560 let is_cte_ref = cte_names.contains(&table);
562
563 let columns = extract_columns(&full_chain);
565
566 usages.push(QailUsage {
567 file: file.to_string(),
568 line: start_line,
569 table,
570 columns,
571 action: action.to_string(),
572 is_cte_ref,
573 });
574
575 i = j.saturating_sub(1);
577 }
578 break; }
580 }
581 i += 1;
582 }
583}
584
585fn extract_string_arg(s: &str) -> Option<String> {
586 let s = s.trim();
588 if s.starts_with('"') {
589 let end = s[1..].find('"')?;
590 Some(s[1..end + 1].to_string())
591 } else {
592 None
593 }
594}
595
596fn extract_columns(line: &str) -> Vec<String> {
597 let mut columns = Vec::new();
598 let mut remaining = line;
599
600 while let Some(pos) = remaining.find(".column(") {
602 let after = &remaining[pos + 8..];
603 if let Some(col) = extract_string_arg(after) {
604 columns.push(col);
605 }
606 remaining = after;
607 }
608
609 remaining = line;
611
612 while let Some(pos) = remaining.find(".filter(") {
614 let after = &remaining[pos + 8..];
615 if let Some(col) = extract_string_arg(after) {
616 if !col.contains('.') {
618 columns.push(col);
619 }
620 }
621 remaining = after;
622 }
623
624 for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
626 let mut temp = line;
627 while let Some(pos) = temp.find(method) {
628 let after = &temp[pos + method.len()..];
629 if let Some(col) = extract_string_arg(after) {
630 if !col.contains('.') {
631 columns.push(col);
632 }
633 }
634 temp = after;
635 }
636 }
637
638 let mut remaining = line;
640 while let Some(pos) = remaining.find(".order_by(") {
641 let after = &remaining[pos + 10..];
642 if let Some(col) = extract_string_arg(after) {
643 if !col.contains('.') {
644 columns.push(col);
645 }
646 }
647 remaining = after;
648 }
649
650 columns
651}
652
653pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
656 use crate::validator::Validator;
657
658 let mut validator = Validator::new();
660 for (table_name, table_schema) in &schema.tables {
661 let cols_with_types: Vec<(&str, &str)> = table_schema.columns
663 .iter()
664 .map(|(name, typ)| (name.as_str(), typ.as_str()))
665 .collect();
666 validator.add_table_with_types(table_name, &cols_with_types);
667 }
668
669 let mut errors = Vec::new();
670
671 for usage in usages {
672 if usage.is_cte_ref {
674 continue;
675 }
676
677 match validator.validate_table(&usage.table) {
679 Ok(()) => {
680 for col in &usage.columns {
682 if col.contains('.') {
684 continue;
685 }
686
687 if let Err(e) = validator.validate_column(&usage.table, col) {
688 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
689 }
690 }
691 }
692 Err(e) => {
693 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
694 }
695 }
696 }
697
698 errors
699}
700
701pub fn validate() {
703 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
704 if Path::new("schema.qail").exists() {
705 "schema".to_string()
706 } else {
707 "false".to_string()
708 }
709 });
710
711 match mode.as_str() {
712 "schema" => {
713 println!("cargo:rerun-if-changed=schema.qail");
714 println!("cargo:rerun-if-changed=migrations");
715 println!("cargo:rerun-if-env-changed=QAIL");
716
717 match Schema::parse_file("schema.qail") {
718 Ok(mut schema) => {
719 let merged = schema.merge_migrations("migrations").unwrap_or(0);
721 if merged > 0 {
722 println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
723 }
724
725 let usages = scan_source_files("src/");
726 let errors = validate_against_schema(&schema, &usages);
727
728 if errors.is_empty() {
729 println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
730 } else {
731 for error in &errors {
732 println!("cargo:warning=QAIL ERROR: {}", error);
733 }
734 panic!("QAIL validation failed with {} errors", errors.len());
736 }
737 }
738 Err(e) => {
739 println!("cargo:warning=QAIL: {}", e);
740 }
741 }
742 }
743 "live" => {
744 println!("cargo:rerun-if-env-changed=QAIL");
745 println!("cargo:rerun-if-env-changed=DATABASE_URL");
746
747 let db_url = match std::env::var("DATABASE_URL") {
749 Ok(url) => url,
750 Err(_) => {
751 panic!("QAIL=live requires DATABASE_URL environment variable");
752 }
753 };
754
755 println!("cargo:warning=QAIL: Pulling schema from live database...");
757
758 let pull_result = std::process::Command::new("qail")
759 .args(["pull", &db_url])
760 .output();
761
762 match pull_result {
763 Ok(output) => {
764 if !output.status.success() {
765 let stderr = String::from_utf8_lossy(&output.stderr);
766 panic!("QAIL: Failed to pull schema: {}", stderr);
767 }
768 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
769 }
770 Err(e) => {
771 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
773
774 let cargo_result = std::process::Command::new("cargo")
775 .args(["run", "-p", "qail", "--", "pull", &db_url])
776 .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
777 .output();
778
779 match cargo_result {
780 Ok(output) if output.status.success() => {
781 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
782 }
783 _ => {
784 panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
785 }
786 }
787 }
788 }
789
790 match Schema::parse_file("schema.qail") {
792 Ok(mut schema) => {
793 let merged = schema.merge_migrations("migrations").unwrap_or(0);
795 if merged > 0 {
796 println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
797 }
798
799 let usages = scan_source_files("src/");
800 let errors = validate_against_schema(&schema, &usages);
801
802 if errors.is_empty() {
803 println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
804 } else {
805 for error in &errors {
806 println!("cargo:warning=QAIL ERROR: {}", error);
807 }
808 panic!("QAIL validation failed with {} errors", errors.len());
809 }
810 }
811 Err(e) => {
812 panic!("QAIL: Failed to parse schema after pull: {}", e);
813 }
814 }
815 }
816 "false" | "off" | "0" => {
817 println!("cargo:rerun-if-env-changed=QAIL");
818 }
820 _ => {
821 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
822 }
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn test_parse_schema() {
832 let content = r#"
834# Test schema
835
836table users {
837 id UUID primary_key
838 name TEXT not_null
839 email TEXT unique
840}
841
842table posts {
843 id UUID
844 user_id UUID
845 title TEXT
846}
847"#;
848 let schema = Schema::parse(content).unwrap();
849 assert!(schema.has_table("users"));
850 assert!(schema.has_table("posts"));
851 assert!(schema.table("users").unwrap().has_column("id"));
852 assert!(schema.table("users").unwrap().has_column("name"));
853 assert!(!schema.table("users").unwrap().has_column("foo"));
854 }
855
856 #[test]
857 fn test_extract_string_arg() {
858 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
859 assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
860 }
861
862 #[test]
863 fn test_scan_file() {
864 let content = r#"
866let query = Qail::get("users").column("id").column("name").eq("active", true);
867"#;
868 let mut usages = Vec::new();
869 scan_file("test.rs", content, &mut usages);
870
871 assert_eq!(usages.len(), 1);
872 assert_eq!(usages[0].table, "users");
873 assert_eq!(usages[0].action, "GET");
874 assert!(usages[0].columns.contains(&"id".to_string()));
875 assert!(usages[0].columns.contains(&"name".to_string()));
876 }
877
878 #[test]
879 fn test_scan_file_multiline() {
880 let content = r#"
882let query = Qail::get("posts")
883 .column("id")
884 .column("title")
885 .column("author")
886 .eq("published", true)
887 .order_by("created_at", Desc);
888"#;
889 let mut usages = Vec::new();
890 scan_file("test.rs", content, &mut usages);
891
892 assert_eq!(usages.len(), 1);
893 assert_eq!(usages[0].table, "posts");
894 assert_eq!(usages[0].action, "GET");
895 assert!(usages[0].columns.contains(&"id".to_string()));
896 assert!(usages[0].columns.contains(&"title".to_string()));
897 assert!(usages[0].columns.contains(&"author".to_string()));
898 }
899}