1use crate::migrate::types::ColumnType;
22use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25#[cfg(feature = "analyzer")]
26use syn::spanned::Spanned;
27#[cfg(feature = "analyzer")]
28use syn::visit::Visit;
29
30#[derive(Debug, Clone)]
32pub struct ForeignKey {
33 pub column: String,
35 pub ref_table: String,
37 pub ref_column: String,
39}
40
41#[derive(Debug, Clone)]
43pub struct TableSchema {
44 pub name: String,
46 pub columns: HashMap<String, ColumnType>,
48 pub policies: HashMap<String, String>,
50 pub foreign_keys: Vec<ForeignKey>,
52 pub rls_enabled: bool,
55}
56
57#[derive(Debug, Default)]
59pub struct Schema {
60 pub tables: HashMap<String, TableSchema>,
62 pub resources: HashMap<String, ResourceSchema>,
64}
65
66#[derive(Debug, Clone)]
68pub struct ResourceSchema {
69 pub name: String,
71 pub kind: String,
73 pub provider: Option<String>,
75 pub properties: HashMap<String, String>,
77}
78
79impl Schema {
80 pub fn parse_file(path: &str) -> Result<Self, String> {
82 let content = crate::schema_source::read_qail_schema_source(path)?;
83 Self::parse(&content)
84 }
85
86 pub fn parse(content: &str) -> Result<Self, String> {
88 let mut schema = Schema::default();
89 let mut current_table: Option<String> = None;
90 let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
91 let mut current_policies: HashMap<String, String> = HashMap::new();
92 let mut current_fks: Vec<ForeignKey> = Vec::new();
93 let mut current_rls_flag = false;
94
95 for line in content.lines() {
96 let line = line.trim();
97
98 if line.is_empty() || line.starts_with('#') {
100 continue;
101 }
102
103 if current_table.is_none()
108 && (line.starts_with("bucket ")
109 || line.starts_with("queue ")
110 || line.starts_with("topic "))
111 {
112 let parts: Vec<&str> = line.splitn(2, ' ').collect();
113 let kind = parts[0].to_string();
114 let rest = parts.get(1).copied().unwrap_or("").trim();
115
116 let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
118 let mut provider = None;
119 let mut properties = HashMap::new();
120
121 if line.contains('{') {
122 let block = rest.split('{').nth(1).unwrap_or("").to_string();
124 if !block.contains('}') {
125 for inner in content.lines().skip_while(|l| !l.contains(line)) {
126 if inner.contains('}') {
128 break;
129 }
130 }
131 }
132 let block = block.replace('}', "");
133 let mut tokens = block.split_whitespace();
134 while let Some(key) = tokens.next() {
135 if let Some(val) = tokens.next() {
136 let val = val.trim_matches('"').to_string();
137 if key == "provider" {
138 provider = Some(val);
139 } else {
140 properties.insert(key.to_string(), val);
141 }
142 }
143 }
144 }
145
146 if !name.is_empty() {
147 schema.resources.insert(
148 name.clone(),
149 ResourceSchema {
150 name,
151 kind,
152 provider,
153 properties,
154 },
155 );
156 }
157 continue;
158 }
159
160 if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
162 if let Some(table_name) = current_table.take() {
164 let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
166 schema.tables.insert(
167 table_name.clone(),
168 TableSchema {
169 name: table_name,
170 columns: std::mem::take(&mut current_columns),
171 policies: std::mem::take(&mut current_policies),
172 foreign_keys: std::mem::take(&mut current_fks),
173 rls_enabled: has_rls,
174 },
175 );
176 }
177
178 let after_table = line.trim_start_matches("table ");
181 let before_brace = after_table.split('{').next().unwrap_or("").trim();
182 let parts: Vec<&str> = before_brace.split_whitespace().collect();
183 let name = parts.first().unwrap_or(&"").to_string();
184 current_rls_flag = parts.contains(&"rls");
185 current_table = Some(name);
186 }
187 else if line == "}" {
189 if let Some(table_name) = current_table.take() {
190 let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
191 schema.tables.insert(
192 table_name.clone(),
193 TableSchema {
194 name: table_name,
195 columns: std::mem::take(&mut current_columns),
196 policies: std::mem::take(&mut current_policies),
197 foreign_keys: std::mem::take(&mut current_fks),
198 rls_enabled: has_rls,
199 },
200 );
201 current_rls_flag = false;
202 }
203 }
204 else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
209 let parts: Vec<&str> = line.split_whitespace().collect();
210 if let Some(col_name) = parts.first() {
211 let col_type_str = parts.get(1).copied().unwrap_or("text");
213 let col_type = col_type_str
214 .parse::<ColumnType>()
215 .unwrap_or(ColumnType::Text);
216 current_columns.insert(col_name.to_string(), col_type);
217
218 let mut policy = "Public".to_string();
220
221 for part in parts.iter().skip(2) {
222 if *part == "protected" {
223 policy = "Protected".to_string();
224 } else if let Some(ref_spec) = part.strip_prefix("ref:") {
225 let ref_spec = ref_spec.trim_start_matches('>');
227 if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
228 current_fks.push(ForeignKey {
229 column: col_name.to_string(),
230 ref_table: ref_table.to_string(),
231 ref_column: ref_col.to_string(),
232 });
233 }
234 }
235 }
236 current_policies.insert(col_name.to_string(), policy);
237 }
238 }
239 }
240
241 Ok(schema)
242 }
243
244 pub fn has_table(&self, name: &str) -> bool {
246 self.tables.contains_key(name)
247 }
248
249 pub fn rls_tables(&self) -> Vec<&str> {
251 self.tables
252 .iter()
253 .filter(|(_, ts)| ts.rls_enabled)
254 .map(|(name, _)| name.as_str())
255 .collect()
256 }
257
258 pub fn is_rls_table(&self, name: &str) -> bool {
260 self.tables.get(name).is_some_and(|t| t.rls_enabled)
261 }
262
263 pub fn table(&self, name: &str) -> Option<&TableSchema> {
265 self.tables.get(name)
266 }
267
268 pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
273 use std::fs;
274
275 let dir = Path::new(migrations_dir);
276 if !dir.exists() {
277 return Ok(0); }
279
280 let mut merged_count = 0;
281
282 let entries =
284 fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
285
286 for entry in entries.flatten() {
287 let path = entry.path();
288
289 let up_sql = if path.is_dir() {
291 path.join("up.sql")
292 } else if path.extension().is_some_and(|e| e == "sql") {
293 path.clone()
294 } else {
295 continue;
296 };
297
298 if up_sql.exists() {
299 let content = fs::read_to_string(&up_sql)
300 .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
301
302 merged_count += self.parse_sql_migration(&content);
303 }
304 }
305
306 Ok(merged_count)
307 }
308
309 fn parse_sql_migration(&mut self, sql: &str) -> usize {
311 let mut changes = 0;
312
313 for line in sql.lines() {
316 let line_upper = line.trim().to_uppercase();
317
318 if line_upper.starts_with("CREATE TABLE")
319 && let Some(table_name) = extract_create_table_name(line)
320 && !self.tables.contains_key(&table_name)
321 {
322 self.tables.insert(
323 table_name.clone(),
324 TableSchema {
325 name: table_name,
326 columns: HashMap::new(),
327 policies: HashMap::new(),
328 foreign_keys: vec![],
329 rls_enabled: false,
330 },
331 );
332 changes += 1;
333 }
334 }
335
336 let mut current_table: Option<String> = None;
338 let mut in_create_block = false;
339 let mut paren_depth = 0;
340
341 for line in sql.lines() {
342 let line = line.trim();
343 let line_upper = line.to_uppercase();
344
345 if line_upper.starts_with("CREATE TABLE")
346 && let Some(name) = extract_create_table_name(line)
347 {
348 current_table = Some(name);
349 in_create_block = true;
350 paren_depth = 0;
351 }
352
353 if in_create_block {
354 paren_depth += line.chars().filter(|c| *c == '(').count();
355 paren_depth =
356 paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
357
358 if let Some(col) = extract_column_from_create(line)
360 && let Some(ref table) = current_table
361 && let Some(t) = self.tables.get_mut(table)
362 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
363 {
364 changes += 1;
365 }
366
367 if paren_depth == 0 && line.contains(')') {
368 in_create_block = false;
369 current_table = None;
370 }
371 }
372
373 if line_upper.contains("ALTER TABLE")
375 && line_upper.contains("ADD COLUMN")
376 && let Some((table, col)) = extract_alter_add_column(line)
377 {
378 if let Some(t) = self.tables.get_mut(&table) {
379 if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
380 changes += 1;
381 }
382 } else {
383 let mut cols = HashMap::new();
385 cols.insert(col, ColumnType::Text);
386 self.tables.insert(
387 table.clone(),
388 TableSchema {
389 name: table,
390 columns: cols,
391 policies: HashMap::new(),
392 foreign_keys: vec![],
393 rls_enabled: false,
394 },
395 );
396 changes += 1;
397 }
398 }
399
400 if line_upper.contains("ALTER TABLE")
402 && line_upper.contains(" ADD ")
403 && !line_upper.contains("ADD COLUMN")
404 && let Some((table, col)) = extract_alter_add(line)
405 && let Some(t) = self.tables.get_mut(&table)
406 && t.columns.insert(col.clone(), ColumnType::Text).is_none()
407 {
408 changes += 1;
409 }
410
411 if line_upper.starts_with("DROP TABLE")
413 && let Some(table_name) = extract_drop_table_name(line)
414 && self.tables.remove(&table_name).is_some()
415 {
416 changes += 1;
417 }
418
419 if line_upper.contains("ALTER TABLE")
421 && line_upper.contains("DROP COLUMN")
422 && let Some((table, col)) = extract_alter_drop_column(line)
423 && let Some(t) = self.tables.get_mut(&table)
424 && t.columns.remove(&col).is_some()
425 {
426 changes += 1;
427 }
428
429 if line_upper.contains("ALTER TABLE")
431 && line_upper.contains(" DROP ")
432 && !line_upper.contains("DROP COLUMN")
433 && !line_upper.contains("DROP CONSTRAINT")
434 && !line_upper.contains("DROP INDEX")
435 && let Some((table, col)) = extract_alter_drop(line)
436 && let Some(t) = self.tables.get_mut(&table)
437 && t.columns.remove(&col).is_some()
438 {
439 changes += 1;
440 }
441 }
442
443 changes
444 }
445}
446
447fn extract_create_table_name(line: &str) -> Option<String> {
449 let line_upper = line.to_uppercase();
450 let rest = line_upper.strip_prefix("CREATE TABLE")?;
451 let rest = rest.trim_start();
452 let rest = if rest.starts_with("IF NOT EXISTS") {
453 rest.strip_prefix("IF NOT EXISTS")?.trim_start()
454 } else {
455 rest
456 };
457
458 let name: String = line[line.len() - rest.len()..]
460 .chars()
461 .take_while(|c| c.is_alphanumeric() || *c == '_')
462 .collect();
463
464 if name.is_empty() {
465 None
466 } else {
467 Some(name.to_lowercase())
468 }
469}
470
471fn extract_column_from_create(line: &str) -> Option<String> {
473 let line = line.trim();
474
475 let line_upper = line.to_uppercase();
480 let starts_with_keyword = |kw: &str| -> bool {
481 line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
482 };
483
484 if starts_with_keyword("CREATE")
485 || starts_with_keyword("PRIMARY")
486 || starts_with_keyword("FOREIGN")
487 || starts_with_keyword("UNIQUE")
488 || starts_with_keyword("CHECK")
489 || starts_with_keyword("CONSTRAINT")
490 || line_upper.starts_with(")")
491 || line_upper.starts_with("(")
492 || line.is_empty()
493 {
494 return None;
495 }
496
497 let name: String = line
499 .trim_start_matches('(')
500 .trim()
501 .chars()
502 .take_while(|c| c.is_alphanumeric() || *c == '_')
503 .collect();
504
505 if name.is_empty() || name.to_uppercase() == "IF" {
506 None
507 } else {
508 Some(name.to_lowercase())
509 }
510}
511
512fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
514 let line_upper = line.to_uppercase();
515 let alter_pos = line_upper.find("ALTER TABLE")?;
516 let add_pos = line_upper.find("ADD COLUMN")?;
517
518 let table_part = &line[alter_pos + 11..add_pos];
520 let table: String = table_part
521 .trim()
522 .chars()
523 .take_while(|c| c.is_alphanumeric() || *c == '_')
524 .collect();
525
526 let mut col_part = &line[add_pos + 10..];
528 let col_upper = col_part.trim().to_uppercase();
529 if col_upper.starts_with("IF NOT EXISTS") {
530 col_part = &col_part.trim()[13..]; }
532 let col: String = col_part
533 .trim()
534 .chars()
535 .take_while(|c| c.is_alphanumeric() || *c == '_')
536 .collect();
537
538 if table.is_empty() || col.is_empty() {
539 None
540 } else {
541 Some((table.to_lowercase(), col.to_lowercase()))
542 }
543}
544
545fn extract_alter_add(line: &str) -> Option<(String, String)> {
547 let line_upper = line.to_uppercase();
548 let alter_pos = line_upper.find("ALTER TABLE")?;
549 let add_pos = line_upper.find(" ADD ")?;
550
551 let table_part = &line[alter_pos + 11..add_pos];
552 let table: String = table_part
553 .trim()
554 .chars()
555 .take_while(|c| c.is_alphanumeric() || *c == '_')
556 .collect();
557
558 let col_part = &line[add_pos + 5..];
559 let col: String = col_part
560 .trim()
561 .chars()
562 .take_while(|c| c.is_alphanumeric() || *c == '_')
563 .collect();
564
565 if table.is_empty() || col.is_empty() {
566 None
567 } else {
568 Some((table.to_lowercase(), col.to_lowercase()))
569 }
570}
571
572fn extract_drop_table_name(line: &str) -> Option<String> {
574 let line_upper = line.to_uppercase();
575 let rest = line_upper.strip_prefix("DROP TABLE")?;
576 let rest = rest.trim_start();
577 let rest = if rest.starts_with("IF EXISTS") {
578 rest.strip_prefix("IF EXISTS")?.trim_start()
579 } else {
580 rest
581 };
582
583 let name: String = line[line.len() - rest.len()..]
585 .chars()
586 .take_while(|c| c.is_alphanumeric() || *c == '_')
587 .collect();
588
589 if name.is_empty() {
590 None
591 } else {
592 Some(name.to_lowercase())
593 }
594}
595
596fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
598 let line_upper = line.to_uppercase();
599 let alter_pos = line_upper.find("ALTER TABLE")?;
600 let drop_pos = line_upper.find("DROP COLUMN")?;
601
602 let table_part = &line[alter_pos + 11..drop_pos];
604 let table: String = table_part
605 .trim()
606 .chars()
607 .take_while(|c| c.is_alphanumeric() || *c == '_')
608 .collect();
609
610 let col_part = &line[drop_pos + 11..];
612 let col: String = col_part
613 .trim()
614 .chars()
615 .take_while(|c| c.is_alphanumeric() || *c == '_')
616 .collect();
617
618 if table.is_empty() || col.is_empty() {
619 None
620 } else {
621 Some((table.to_lowercase(), col.to_lowercase()))
622 }
623}
624
625fn extract_alter_drop(line: &str) -> Option<(String, String)> {
627 let line_upper = line.to_uppercase();
628 let alter_pos = line_upper.find("ALTER TABLE")?;
629 let drop_pos = line_upper.find(" DROP ")?;
630
631 let table_part = &line[alter_pos + 11..drop_pos];
632 let table: String = table_part
633 .trim()
634 .chars()
635 .take_while(|c| c.is_alphanumeric() || *c == '_')
636 .collect();
637
638 let col_part = &line[drop_pos + 6..];
639 let col: String = col_part
640 .trim()
641 .chars()
642 .take_while(|c| c.is_alphanumeric() || *c == '_')
643 .collect();
644
645 if table.is_empty() || col.is_empty() {
646 None
647 } else {
648 Some((table.to_lowercase(), col.to_lowercase()))
649 }
650}
651
652impl TableSchema {
653 pub fn has_column(&self, name: &str) -> bool {
655 self.columns.contains_key(name)
656 }
657
658 pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
660 self.columns.get(name)
661 }
662
663 pub fn primary_key_column(&self) -> &str {
669 if self.columns.contains_key("id") {
670 "id"
671 } else {
672 let singular = self.name.trim_end_matches('s');
675 let conventional = format!("{}_id", singular);
676 if self.columns.contains_key(&conventional) {
677 return "id"; }
681 "id" }
683 }
684}
685
686#[derive(Debug)]
688pub struct QailUsage {
689 pub file: String,
691 pub line: usize,
693 pub table: String,
695 pub columns: Vec<String>,
697 pub action: String,
699 pub is_cte_ref: bool,
701 pub has_rls: bool,
703}
704
705pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
707 let mut usages = Vec::new();
708 scan_directory(Path::new(src_dir), &mut usages);
709 usages
710}
711
712fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
713 if let Ok(entries) = fs::read_dir(dir) {
714 for entry in entries.flatten() {
715 let path = entry.path();
716 if path.is_dir() {
717 scan_directory(&path, usages);
718 } else if path.extension().is_some_and(|e| e == "rs")
719 && let Ok(content) = fs::read_to_string(&path)
720 {
721 scan_file(&path.display().to_string(), &content, usages);
722 }
723 }
724 }
725}
726
727fn collect_let_bindings(content: &str) -> HashMap<String, Vec<String>> {
736 let mut bindings: HashMap<String, Vec<String>> = HashMap::new();
737
738 let lines: Vec<&str> = content.lines().collect();
740 let mut i = 0;
741
742 while i < lines.len() {
743 let line = lines[i].trim();
744
745 if let Some(rest) = line.strip_prefix("let ") {
748 let rest = rest.trim();
749
750 if let Some((var, rhs)) = parse_simple_let(rest) {
752 if let Some(lit) = extract_string_arg(rhs.trim()) {
753 bindings.entry(var).or_default().push(lit);
754 i += 1;
755 continue;
756 }
757
758 let rhs = rhs.trim();
760 if rhs.starts_with("if ") {
761 let mut full_expr = rhs.to_string();
763 let mut j = i + 1;
764 while j < lines.len() && !full_expr.contains(';') {
766 full_expr.push(' ');
767 full_expr.push_str(lines[j].trim());
768 j += 1;
769 }
770 let literals = extract_branch_literals(&full_expr);
771 if !literals.is_empty() {
772 bindings.entry(var).or_default().extend(literals);
773 }
774 }
775 }
776
777 if rest.starts_with('(') {
780 let mut full_line = line.to_string();
782 let mut j = i + 1;
783 while j < lines.len() && !full_line.contains(';') {
784 full_line.push(' ');
785 full_line.push_str(lines[j].trim());
786 j += 1;
787 }
788
789 if let Some(result) = parse_destructuring_let(&full_line) {
790 for (name, values) in result {
791 bindings.entry(name).or_default().extend(values);
792 }
793 }
794 }
795 }
796
797 i += 1;
798 }
799
800 bindings
801}
802
803fn parse_simple_let(s: &str) -> Option<(String, &str)> {
806 let s = s.strip_prefix("mut ").unwrap_or(s).trim();
808 if s.starts_with('(') {
809 return None;
810 }
811
812 let ident: String = s
814 .chars()
815 .take_while(|c| c.is_alphanumeric() || *c == '_')
816 .collect();
817 if ident.is_empty() {
818 return None;
819 }
820
821 let rest = s[ident.len()..].trim_start();
823 let rest = if rest.starts_with(':') {
824 rest.find('=').map(|pos| &rest[pos..])?
826 } else {
827 rest
828 };
829
830 let rest = rest.strip_prefix('=')?.trim();
831 Some((ident, rest))
832}
833
834fn extract_branch_literals(expr: &str) -> Vec<String> {
837 let mut literals = Vec::new();
838
839 let mut remaining = expr;
841 while let Some(brace_pos) = remaining.find('{') {
842 let inside = &remaining[brace_pos + 1..];
843 if let Some(close_pos) = inside.find('}') {
844 let block = inside[..close_pos].trim();
845 if let Some(lit) = extract_string_arg(block) {
847 literals.push(lit);
848 }
849 remaining = &inside[close_pos + 1..];
850 } else {
851 break;
852 }
853 }
854
855 literals
856}
857
858fn parse_destructuring_let(line: &str) -> Option<Vec<(String, Vec<String>)>> {
861 let rest = line.strip_prefix("let ")?.trim();
863 let rest = rest.strip_prefix("mut ").unwrap_or(rest).trim();
864 let rest = rest.strip_prefix('(')?;
865
866 let close_paren = rest.find(')')?;
868 let names_str = &rest[..close_paren];
869 let names: Vec<String> = names_str
870 .split(',')
871 .map(|s| s.trim().to_string())
872 .filter(|s| !s.is_empty() && !s.starts_with('_'))
873 .collect();
874
875 if names.is_empty() {
876 return None;
877 }
878
879 let after_pattern = &rest[close_paren + 1..];
881 let eq_pos = after_pattern.find('=')?;
882 let rhs = after_pattern[eq_pos + 1..].trim();
883
884 if rhs.starts_with('(') {
886 let values = extract_tuple_literals(rhs);
887 if values.len() == names.len() {
888 return Some(
889 names
890 .into_iter()
891 .zip(values)
892 .map(|(n, v)| (n, vec![v]))
893 .collect(),
894 );
895 }
896 }
897
898 if rhs.starts_with("if ") {
900 let mut all_tuples: Vec<Vec<String>> = Vec::new();
901
902 let mut remaining = rhs;
904 while let Some(brace_pos) = remaining.find('{') {
905 let inside = &remaining[brace_pos + 1..];
906 if let Some(close_pos) = find_matching_brace(inside) {
907 let block = inside[..close_pos].trim();
908 if block.starts_with('(') {
910 let values = extract_tuple_literals(block);
911 if values.len() == names.len() {
912 all_tuples.push(values);
913 }
914 }
915 remaining = &inside[close_pos + 1..];
916 } else {
917 break;
918 }
919 }
920
921 if !all_tuples.is_empty() {
922 let mut result: Vec<(String, Vec<String>)> =
923 names.iter().map(|n| (n.clone(), Vec::new())).collect();
924
925 for tuple in &all_tuples {
926 for (i, val) in tuple.iter().enumerate() {
927 if i < result.len() {
928 result[i].1.push(val.clone());
929 }
930 }
931 }
932
933 return Some(result);
934 }
935 }
936
937 None
938}
939
940fn extract_tuple_literals(s: &str) -> Vec<String> {
942 let mut literals = Vec::new();
943 let s = s.trim();
944 let s = s.strip_prefix('(').unwrap_or(s);
945 let content = if let Some(pos) = s.rfind(')') {
947 &s[..pos]
948 } else {
949 s.trim_end_matches(';').trim_end_matches(')')
950 };
951
952 for part in content.split(',') {
953 let part = part.trim();
954 if let Some(lit) = extract_string_arg(part) {
955 literals.push(lit);
956 }
957 }
958 literals
959}
960
961fn find_matching_brace(s: &str) -> Option<usize> {
964 let mut depth = 0i32;
965 for (i, ch) in s.chars().enumerate() {
966 match ch {
967 '{' => depth += 1,
968 '}' => {
969 if depth == 0 {
970 return Some(i);
971 }
972 depth -= 1;
973 }
974 _ => {}
975 }
976 }
977 None
978}
979
980fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
981 let patterns = [
984 ("Qail::get(", "GET"),
985 ("Qail::add(", "ADD"),
986 ("Qail::set(", "SET"),
987 ("Qail::del(", "DEL"),
988 ("Qail::put(", "PUT"),
989 ("Qail::typed(", "TYPED"),
990 ("Qail::raw_sql(", "RAW"),
991 ];
992
993 let let_bindings = collect_let_bindings(content);
995
996 let mut file_cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1001 for line in content.lines() {
1002 let line = line.trim();
1003 if let Some(pos) = line.find(".to_cte(") {
1005 let after = &line[pos + 8..];
1006 if let Some(name) = extract_string_arg(after) {
1007 file_cte_names.insert(name);
1008 }
1009 }
1010 if let Some(pos) = line.find(".with(") {
1012 let after = &line[pos + 6..];
1013 if let Some(name) = extract_string_arg(after) {
1014 file_cte_names.insert(name);
1015 }
1016 }
1017 }
1018
1019 let lines: Vec<&str> = content.lines().collect();
1021 let mut i = 0;
1022
1023 while i < lines.len() {
1024 let line = lines[i].trim();
1025
1026 for (pattern, action) in &patterns {
1028 if let Some(pos) = line.find(pattern) {
1029 let start_line = i + 1; let after = &line[pos + pattern.len()..];
1033
1034 let table = if *action == "TYPED" {
1035 extract_typed_table_arg(after)
1037 } else {
1038 extract_string_arg(after)
1039 };
1040
1041 if *action == "RAW" {
1042 println!(
1044 "cargo:warning=QAIL: raw SQL at {}:{} — not schema-validated",
1045 file, start_line
1046 );
1047 break;
1048 }
1049
1050 if let Some(table) = table {
1051 let mut full_chain = line.to_string();
1053 let mut j = i + 1;
1054 while j < lines.len() {
1055 let next = lines[j].trim();
1056 if next.starts_with('.') {
1057 full_chain.push_str(next);
1058 j += 1;
1059 } else if next.is_empty() {
1060 j += 1; } else {
1062 break;
1063 }
1064 }
1065
1066 let is_cte_ref = file_cte_names.contains(&table);
1069
1070 let has_rls = full_chain.contains(".with_rls(") || full_chain.contains(".rls(");
1072
1073 let columns = extract_columns(&full_chain);
1075
1076 usages.push(QailUsage {
1077 file: file.to_string(),
1078 line: start_line,
1079 table,
1080 columns,
1081 action: action.to_string(),
1082 is_cte_ref,
1083 has_rls,
1084 });
1085
1086 i = j.saturating_sub(1);
1088 } else if *action != "TYPED" {
1089 let var_hint = after.split(')').next().unwrap_or("?").trim();
1091
1092 let lookup_key = var_hint.rsplit('.').next().unwrap_or(var_hint);
1094
1095 if let Some(resolved_tables) = let_bindings.get(lookup_key) {
1096 let mut full_chain = line.to_string();
1099 let mut j = i + 1;
1100 while j < lines.len() {
1101 let next = lines[j].trim();
1102 if next.starts_with('.') {
1103 full_chain.push_str(next);
1104 j += 1;
1105 } else if next.is_empty() {
1106 j += 1;
1107 } else {
1108 break;
1109 }
1110 }
1111 let columns = extract_columns(&full_chain);
1112 let has_rls =
1113 full_chain.contains(".with_rls(") || full_chain.contains(".rls(");
1114
1115 for resolved_table in resolved_tables {
1116 let is_cte_ref = file_cte_names.contains(resolved_table);
1117 usages.push(QailUsage {
1118 file: file.to_string(),
1119 line: start_line,
1120 table: resolved_table.clone(),
1121 columns: columns.clone(),
1122 action: action.to_string(),
1123 is_cte_ref,
1124 has_rls,
1125 });
1126 }
1127 i = j.saturating_sub(1);
1128 } else {
1129 println!(
1131 "cargo:warning=Qail: dynamic table name `{}` in {}:{} — cannot validate columns at build time. Consider using string literals.",
1132 var_hint, file, start_line
1133 );
1134 }
1135 }
1136 break; }
1139 }
1140 i += 1;
1141 }
1142}
1143
1144fn extract_string_arg(s: &str) -> Option<String> {
1145 let s = s.trim();
1147 if let Some(stripped) = s.strip_prefix('"') {
1148 let end = stripped.find('"')?;
1149 Some(stripped[..end].to_string())
1150 } else {
1151 None
1152 }
1153}
1154
1155fn extract_typed_table_arg(s: &str) -> Option<String> {
1165 let s = s.trim();
1166 let ident: String = s
1168 .chars()
1169 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
1170 .collect();
1171
1172 let segments: Vec<&str> = ident.split("::").filter(|s| !s.is_empty()).collect();
1173
1174 match segments.len() {
1175 0 => None,
1176 1 => {
1177 let name = segments[0];
1179 if name.chars().all(|c| c.is_alphanumeric() || c == '_') {
1180 Some(name.to_lowercase())
1181 } else {
1182 None
1183 }
1184 }
1185 _ => {
1186 let table = segments[segments.len() - 2];
1189 if table.chars().all(|c| c.is_alphanumeric() || c == '_') {
1190 Some(table.to_lowercase())
1191 } else {
1192 None
1193 }
1194 }
1195 }
1196}
1197
1198fn extract_columns(line: &str) -> Vec<String> {
1199 let mut columns = Vec::new();
1200 let mut remaining = line;
1201
1202 while let Some(pos) = remaining.find(".column(") {
1204 let after = &remaining[pos + 8..];
1205 if let Some(col) = extract_string_arg(after) {
1206 columns.push(col);
1207 }
1208 remaining = after;
1209 }
1210
1211 remaining = line;
1213 while let Some(pos) = remaining.find(".columns(") {
1214 let after = &remaining[pos + 9..];
1215 if let Some(bracket_start) = after.find('[') {
1217 let inside = &after[bracket_start + 1..];
1218 if let Some(bracket_end) = inside.find(']') {
1220 let array_content = &inside[..bracket_end];
1221 let mut scan = array_content;
1223 while let Some(quote_start) = scan.find('"') {
1224 let after_quote = &scan[quote_start + 1..];
1225 if let Some(quote_end) = after_quote.find('"') {
1226 let col = &after_quote[..quote_end];
1227 if !col.is_empty() {
1228 columns.push(col.to_string());
1229 }
1230 scan = &after_quote[quote_end + 1..];
1231 } else {
1232 break;
1233 }
1234 }
1235 }
1236 }
1237 remaining = after;
1238 }
1239
1240 remaining = line;
1242
1243 while let Some(pos) = remaining.find(".filter(") {
1245 let after = &remaining[pos + 8..];
1246 if let Some(col) = extract_string_arg(after)
1247 && !col.contains('.')
1248 {
1249 columns.push(col);
1250 }
1251 remaining = after;
1252 }
1253
1254 for method in [
1256 ".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike(",
1257 ] {
1258 let mut temp = line;
1259 while let Some(pos) = temp.find(method) {
1260 let after = &temp[pos + method.len()..];
1261 if let Some(col) = extract_string_arg(after)
1262 && !col.contains('.')
1263 {
1264 columns.push(col);
1265 }
1266 temp = after;
1267 }
1268 }
1269
1270 remaining = line;
1272 while let Some(pos) = remaining.find(".where_eq(") {
1273 let after = &remaining[pos + 10..];
1274 if let Some(col) = extract_string_arg(after)
1275 && !col.contains('.')
1276 {
1277 columns.push(col);
1278 }
1279 remaining = after;
1280 }
1281
1282 remaining = line;
1284 while let Some(pos) = remaining.find(".order_by(") {
1285 let after = &remaining[pos + 10..];
1286 if let Some(col) = extract_string_arg(after)
1287 && !col.contains('.')
1288 {
1289 columns.push(col);
1290 }
1291 remaining = after;
1292 }
1293
1294 for method in [".order_desc(", ".order_asc("] {
1296 let mut temp = line;
1297 while let Some(pos) = temp.find(method) {
1298 let after = &temp[pos + method.len()..];
1299 if let Some(col) = extract_string_arg(after)
1300 && !col.contains('.')
1301 {
1302 columns.push(col);
1303 }
1304 temp = after;
1305 }
1306 }
1307
1308 remaining = line;
1310 while let Some(pos) = remaining.find(".in_vals(") {
1311 let after = &remaining[pos + 9..];
1312 if let Some(col) = extract_string_arg(after)
1313 && !col.contains('.')
1314 {
1315 columns.push(col);
1316 }
1317 remaining = after;
1318 }
1319
1320 for method in [".is_null(", ".is_not_null("] {
1324 let mut temp = line;
1325 while let Some(pos) = temp.find(method) {
1326 let after = &temp[pos + method.len()..];
1327 if let Some(col) = extract_string_arg(after)
1328 && !col.contains('.')
1329 {
1330 columns.push(col);
1331 }
1332 temp = after;
1333 }
1334 }
1335
1336 for method in [".set_value(", ".set_coalesce(", ".set_coalesce_opt("] {
1338 let mut temp = line;
1339 while let Some(pos) = temp.find(method) {
1340 let after = &temp[pos + method.len()..];
1341 if let Some(col) = extract_string_arg(after)
1342 && !col.contains('.')
1343 {
1344 columns.push(col);
1345 }
1346 temp = after;
1347 }
1348 }
1349
1350 remaining = line;
1352 while let Some(pos) = remaining.find(".returning(") {
1353 let after = &remaining[pos + 11..];
1354 if let Some(bracket_start) = after.find('[') {
1355 let inside = &after[bracket_start + 1..];
1356 if let Some(bracket_end) = inside.find(']') {
1357 let array_content = &inside[..bracket_end];
1358 let mut scan = array_content;
1359 while let Some(quote_start) = scan.find('"') {
1360 let after_quote = &scan[quote_start + 1..];
1361 if let Some(quote_end) = after_quote.find('"') {
1362 let col = &after_quote[..quote_end];
1363 if !col.is_empty() && !col.contains('.') {
1364 columns.push(col.to_string());
1365 }
1366 scan = &after_quote[quote_end + 1..];
1367 } else {
1368 break;
1369 }
1370 }
1371 }
1372 }
1373 remaining = after;
1374 }
1375
1376 for method in [".on_conflict_update(", ".on_conflict_nothing("] {
1379 let mut temp = line;
1380 while let Some(pos) = temp.find(method) {
1381 let after = &temp[pos + method.len()..];
1382 if let Some(bracket_start) = after.find('[') {
1383 let inside = &after[bracket_start + 1..];
1384 if let Some(bracket_end) = inside.find(']') {
1385 let array_content = &inside[..bracket_end];
1386 let mut scan = array_content;
1387 while let Some(quote_start) = scan.find('"') {
1388 let after_quote = &scan[quote_start + 1..];
1389 if let Some(quote_end) = after_quote.find('"') {
1390 let col = &after_quote[..quote_end];
1391 if !col.is_empty() && !col.contains('.') {
1392 columns.push(col.to_string());
1393 }
1394 scan = &after_quote[quote_end + 1..];
1395 } else {
1396 break;
1397 }
1398 }
1399 }
1400 }
1401 temp = after;
1402 }
1403 }
1404
1405 let columns: Vec<String> = columns
1409 .into_iter()
1410 .map(|col| {
1411 let col = if let Some(pos) = col.find(" as ").or_else(|| col.find(" AS ")) {
1413 col[..pos].trim().to_string()
1414 } else {
1415 col
1416 };
1417 if let Some(pos) = col.find("::") {
1419 col[..pos].to_string()
1420 } else {
1421 col
1422 }
1423 })
1424 .filter(|col| {
1425 !col.contains('(') && !col.contains(')') && !col.contains(' ')
1427 })
1428 .collect();
1429
1430 columns
1431}
1432
1433fn usage_action_to_ast(action: &str) -> crate::ast::Action {
1434 use crate::ast::Action;
1435
1436 match action {
1437 "GET" | "TYPED" => Action::Get,
1438 "ADD" => Action::Add,
1439 "SET" => Action::Set,
1440 "DEL" => Action::Del,
1441 "PUT" => Action::Put,
1442 _ => Action::Get,
1443 }
1444}
1445
1446fn append_scanned_columns(cmd: &mut crate::ast::Qail, columns: &[String]) {
1447 use crate::ast::Expr;
1448
1449 for col in columns {
1450 if col.contains('.') {
1452 continue;
1453 }
1454 if col.contains('(') || col == "*" {
1457 continue;
1458 }
1459 let exists = cmd
1460 .columns
1461 .iter()
1462 .any(|e| matches!(e, Expr::Named(existing) if existing == col));
1463 if !exists {
1464 cmd.columns.push(Expr::Named(col.clone()));
1465 }
1466 }
1467}
1468
1469#[cfg(feature = "analyzer")]
1470type SynUsageKey = (String, usize, String, String);
1471
1472#[cfg(feature = "analyzer")]
1473#[derive(Debug, Clone)]
1474struct SynParsedUsage {
1475 line: usize,
1476 action: String,
1477 table: String,
1478 cmd: crate::ast::Qail,
1479 has_rls: bool,
1480 score: usize,
1481}
1482
1483#[cfg(feature = "analyzer")]
1484struct SynMethodStep {
1485 name: String,
1486 args: Vec<syn::Expr>,
1487}
1488
1489#[cfg(feature = "analyzer")]
1490#[derive(Debug)]
1491struct SynConstructor {
1492 line: usize,
1493 action: String,
1494 ast_action: crate::ast::Action,
1495 table: String,
1496}
1497
1498#[cfg(feature = "analyzer")]
1499fn syn_usage_key(file: &str, line: usize, action: &str, table: &str) -> SynUsageKey {
1500 (
1501 file.to_string(),
1502 line,
1503 action.to_string(),
1504 table.to_string(),
1505 )
1506}
1507
1508#[cfg(feature = "analyzer")]
1509fn build_syn_usage_index(usages: &[QailUsage]) -> HashMap<SynUsageKey, SynParsedUsage> {
1510 let mut files = std::collections::HashSet::new();
1511 for usage in usages {
1512 files.insert(usage.file.clone());
1513 }
1514
1515 let mut index: HashMap<SynUsageKey, SynParsedUsage> = HashMap::new();
1516 for file in files {
1517 for parsed in extract_syn_usages_from_file(&file) {
1518 let key = syn_usage_key(&file, parsed.line, &parsed.action, &parsed.table);
1519 match index.get(&key) {
1520 Some(existing) if existing.score >= parsed.score => {}
1521 _ => {
1522 index.insert(key, parsed);
1523 }
1524 }
1525 }
1526 }
1527
1528 index
1529}
1530
1531#[cfg(feature = "analyzer")]
1532fn extract_syn_usages_from_file(file: &str) -> Vec<SynParsedUsage> {
1533 let Ok(content) = fs::read_to_string(file) else {
1534 return Vec::new();
1535 };
1536 extract_syn_usages_from_source(&content)
1537}
1538
1539#[cfg(feature = "analyzer")]
1540fn extract_syn_usages_from_source(source: &str) -> Vec<SynParsedUsage> {
1541 let Ok(syntax) = syn::parse_file(source) else {
1542 return Vec::new();
1543 };
1544
1545 struct SynQailVisitor {
1546 usages: Vec<SynParsedUsage>,
1547 }
1548
1549 impl SynQailVisitor {
1550 fn new() -> Self {
1551 Self { usages: Vec::new() }
1552 }
1553 }
1554
1555 impl<'ast> Visit<'ast> for SynQailVisitor {
1556 fn visit_expr(&mut self, node: &'ast syn::Expr) {
1557 if let Some(parsed) = parse_qail_chain_from_expr(node) {
1558 self.usages.push(parsed);
1559 }
1560 syn::visit::visit_expr(self, node);
1561 }
1562 }
1563
1564 let mut visitor = SynQailVisitor::new();
1565 visitor.visit_file(&syntax);
1566 visitor.usages
1567}
1568
1569#[cfg(feature = "analyzer")]
1570fn parse_qail_chain_from_expr(expr: &syn::Expr) -> Option<SynParsedUsage> {
1571 let mut steps = Vec::<SynMethodStep>::new();
1572 let mut cursor = expr;
1573
1574 loop {
1575 match cursor {
1576 syn::Expr::MethodCall(method) => {
1577 steps.push(SynMethodStep {
1578 name: method.method.to_string(),
1579 args: method.args.iter().cloned().collect(),
1580 });
1581 cursor = &method.receiver;
1582 }
1583 syn::Expr::Call(call) => {
1584 let ctor = parse_qail_constructor(call)?;
1585 steps.reverse();
1586
1587 let mut cmd = crate::ast::Qail {
1588 action: ctor.ast_action,
1589 table: ctor.table.clone(),
1590 ..Default::default()
1591 };
1592 let mut has_rls = false;
1593
1594 for step in steps {
1595 apply_syn_method_step(&mut cmd, &step.name, &step.args, &mut has_rls);
1596 }
1597
1598 let score = syn_cmd_score(&cmd, has_rls);
1599 return Some(SynParsedUsage {
1600 line: ctor.line,
1601 action: ctor.action,
1602 table: ctor.table,
1603 cmd,
1604 has_rls,
1605 score,
1606 });
1607 }
1608 syn::Expr::Paren(paren) => cursor = &paren.expr,
1609 syn::Expr::Group(group) => cursor = &group.expr,
1610 syn::Expr::Reference(reference) => cursor = &reference.expr,
1611 _ => return None,
1612 }
1613 }
1614}
1615
1616#[cfg(feature = "analyzer")]
1617fn parse_qail_constructor(call: &syn::ExprCall) -> Option<SynConstructor> {
1618 let syn::Expr::Path(path_expr) = &*call.func else {
1619 return None;
1620 };
1621
1622 let ctor = qail_constructor_name(&path_expr.path)?;
1623 let first_arg = call.args.first()?;
1624
1625 let (action, ast_action, table) = match ctor.as_str() {
1626 "get" => (
1627 "GET".to_string(),
1628 crate::ast::Action::Get,
1629 parse_table_name_from_expr(first_arg)?,
1630 ),
1631 "add" => (
1632 "ADD".to_string(),
1633 crate::ast::Action::Add,
1634 parse_table_name_from_expr(first_arg)?,
1635 ),
1636 "set" => (
1637 "SET".to_string(),
1638 crate::ast::Action::Set,
1639 parse_table_name_from_expr(first_arg)?,
1640 ),
1641 "del" => (
1642 "DEL".to_string(),
1643 crate::ast::Action::Del,
1644 parse_table_name_from_expr(first_arg)?,
1645 ),
1646 "put" => (
1647 "PUT".to_string(),
1648 crate::ast::Action::Put,
1649 parse_table_name_from_expr(first_arg)?,
1650 ),
1651 "typed" => (
1652 "TYPED".to_string(),
1653 crate::ast::Action::Get,
1654 parse_typed_table_from_expr(first_arg)?,
1655 ),
1656 _ => return None,
1658 };
1659
1660 Some(SynConstructor {
1661 line: call.span().start().line,
1662 action,
1663 ast_action,
1664 table,
1665 })
1666}
1667
1668#[cfg(feature = "analyzer")]
1669fn qail_constructor_name(path: &syn::Path) -> Option<String> {
1670 let mut segments = path.segments.iter().map(|s| s.ident.to_string());
1671 let first = segments.next()?;
1672 let mut prev = first;
1673 for segment in segments {
1674 if prev == "Qail" {
1675 return Some(segment.to_ascii_lowercase());
1676 }
1677 prev = segment;
1678 }
1679 None
1680}
1681
1682#[cfg(feature = "analyzer")]
1683fn parse_typed_table_from_expr(expr: &syn::Expr) -> Option<String> {
1684 match expr {
1685 syn::Expr::Path(path_expr) => {
1686 let segments: Vec<_> = path_expr
1687 .path
1688 .segments
1689 .iter()
1690 .map(|s| s.ident.to_string())
1691 .collect();
1692 match segments.len() {
1693 0 => None,
1694 1 => Some(segments[0].to_ascii_lowercase()),
1695 _ => Some(segments[segments.len() - 2].to_ascii_lowercase()),
1696 }
1697 }
1698 syn::Expr::Reference(reference) => parse_typed_table_from_expr(&reference.expr),
1699 syn::Expr::Paren(paren) => parse_typed_table_from_expr(&paren.expr),
1700 syn::Expr::Group(group) => parse_typed_table_from_expr(&group.expr),
1701 syn::Expr::MethodCall(method) if method.method == "into" => {
1702 parse_typed_table_from_expr(&method.receiver)
1703 }
1704 _ => None,
1705 }
1706}
1707
1708#[cfg(feature = "analyzer")]
1709fn parse_table_name_from_expr(expr: &syn::Expr) -> Option<String> {
1710 parse_string_from_expr(expr).or_else(|| parse_typed_table_from_expr(expr))
1711}
1712
1713#[cfg(feature = "analyzer")]
1714fn parse_string_from_expr(expr: &syn::Expr) -> Option<String> {
1715 match expr {
1716 syn::Expr::Lit(lit) => match &lit.lit {
1717 syn::Lit::Str(s) => Some(s.value()),
1718 _ => None,
1719 },
1720 syn::Expr::Reference(reference) => parse_string_from_expr(&reference.expr),
1721 syn::Expr::Paren(paren) => parse_string_from_expr(&paren.expr),
1722 syn::Expr::Group(group) => parse_string_from_expr(&group.expr),
1723 syn::Expr::MethodCall(method)
1724 if method.method == "into" || method.method == "to_string" =>
1725 {
1726 parse_string_from_expr(&method.receiver)
1727 }
1728 syn::Expr::Call(call) => {
1729 let syn::Expr::Path(path_expr) = &*call.func else {
1730 return None;
1731 };
1732 let tail = path_expr.path.segments.last()?.ident.to_string();
1733 if tail == "from" || tail == "new" || tail == "String" {
1734 return call.args.first().and_then(parse_string_from_expr);
1735 }
1736 None
1737 }
1738 _ => None,
1739 }
1740}
1741
1742#[cfg(feature = "analyzer")]
1743fn parse_string_list_from_expr(expr: &syn::Expr) -> Vec<String> {
1744 match expr {
1745 syn::Expr::Array(arr) => arr
1746 .elems
1747 .iter()
1748 .filter_map(parse_string_from_expr)
1749 .collect(),
1750 syn::Expr::Reference(reference) => parse_string_list_from_expr(&reference.expr),
1751 syn::Expr::Paren(paren) => parse_string_list_from_expr(&paren.expr),
1752 syn::Expr::Group(group) => parse_string_list_from_expr(&group.expr),
1753 syn::Expr::Macro(mac) if mac.mac.path.is_ident("vec") => {
1754 if let Ok(arr) = syn::parse2::<syn::ExprArray>(mac.mac.tokens.clone()) {
1755 return arr
1756 .elems
1757 .iter()
1758 .filter_map(parse_string_from_expr)
1759 .collect();
1760 }
1761 Vec::new()
1762 }
1763 _ => parse_string_from_expr(expr).into_iter().collect(),
1764 }
1765}
1766
1767#[cfg(feature = "analyzer")]
1768fn parse_operator_from_expr(expr: &syn::Expr) -> Option<crate::ast::Operator> {
1769 let syn::Expr::Path(path_expr) = expr else {
1770 return None;
1771 };
1772 let name = path_expr.path.segments.last()?.ident.to_string();
1773 Some(match name.as_str() {
1774 "Eq" => crate::ast::Operator::Eq,
1775 "Ne" => crate::ast::Operator::Ne,
1776 "Gt" => crate::ast::Operator::Gt,
1777 "Gte" => crate::ast::Operator::Gte,
1778 "Lt" => crate::ast::Operator::Lt,
1779 "Lte" => crate::ast::Operator::Lte,
1780 "Like" => crate::ast::Operator::Like,
1781 "ILike" => crate::ast::Operator::ILike,
1782 "IsNull" => crate::ast::Operator::IsNull,
1783 "IsNotNull" => crate::ast::Operator::IsNotNull,
1784 "In" => crate::ast::Operator::In,
1785 _ => return None,
1786 })
1787}
1788
1789#[cfg(feature = "analyzer")]
1790fn parse_sort_order_from_expr(expr: &syn::Expr) -> Option<crate::ast::SortOrder> {
1791 let syn::Expr::Path(path_expr) = expr else {
1792 return None;
1793 };
1794 let name = path_expr.path.segments.last()?.ident.to_string();
1795 Some(match name.as_str() {
1796 "Asc" => crate::ast::SortOrder::Asc,
1797 "Desc" => crate::ast::SortOrder::Desc,
1798 "AscNullsFirst" => crate::ast::SortOrder::AscNullsFirst,
1799 "AscNullsLast" => crate::ast::SortOrder::AscNullsLast,
1800 "DescNullsFirst" => crate::ast::SortOrder::DescNullsFirst,
1801 "DescNullsLast" => crate::ast::SortOrder::DescNullsLast,
1802 _ => return None,
1803 })
1804}
1805
1806#[cfg(feature = "analyzer")]
1807fn parse_join_kind_from_expr(expr: &syn::Expr) -> Option<crate::ast::JoinKind> {
1808 let syn::Expr::Path(path_expr) = expr else {
1809 return None;
1810 };
1811 let name = path_expr.path.segments.last()?.ident.to_string();
1812 Some(match name.as_str() {
1813 "Inner" => crate::ast::JoinKind::Inner,
1814 "Left" => crate::ast::JoinKind::Left,
1815 "Right" => crate::ast::JoinKind::Right,
1816 "Lateral" => crate::ast::JoinKind::Lateral,
1817 "Full" => crate::ast::JoinKind::Full,
1818 "Cross" => crate::ast::JoinKind::Cross,
1819 _ => return None,
1820 })
1821}
1822
1823#[cfg(feature = "analyzer")]
1824fn parse_value_ctor_call(call: &syn::ExprCall) -> Option<crate::ast::Value> {
1825 let syn::Expr::Path(path_expr) = &*call.func else {
1826 return None;
1827 };
1828 let segments: Vec<String> = path_expr
1829 .path
1830 .segments
1831 .iter()
1832 .map(|s| s.ident.to_string())
1833 .collect();
1834 if segments.len() < 2 || segments[segments.len() - 2] != "Value" {
1835 return None;
1836 }
1837
1838 let ctor = segments.last()?.as_str();
1839 let first = call.args.first();
1840
1841 use crate::ast::Value;
1842 Some(match ctor {
1843 "Null" => Value::Null,
1844 "Bool" => match first {
1845 Some(syn::Expr::Lit(lit)) => match &lit.lit {
1846 syn::Lit::Bool(b) => Value::Bool(b.value),
1847 _ => return None,
1848 },
1849 _ => return None,
1850 },
1851 "Int" => match first {
1852 Some(syn::Expr::Lit(lit)) => match &lit.lit {
1853 syn::Lit::Int(i) => i
1854 .base10_parse::<i64>()
1855 .map(Value::Int)
1856 .unwrap_or(Value::Null),
1857 _ => return None,
1858 },
1859 _ => return None,
1860 },
1861 "Float" => match first {
1862 Some(syn::Expr::Lit(lit)) => match &lit.lit {
1863 syn::Lit::Float(f) => f
1864 .base10_parse::<f64>()
1865 .map(Value::Float)
1866 .unwrap_or(Value::Null),
1867 _ => return None,
1868 },
1869 _ => return None,
1870 },
1871 "String" => Value::String(first.and_then(parse_string_from_expr)?),
1872 "Column" => Value::Column(first.and_then(parse_string_from_expr)?),
1873 "Array" => Value::Array(match first {
1874 Some(expr) => match parse_value_from_expr(expr) {
1875 Value::Array(arr) => arr,
1876 single => vec![single],
1877 },
1878 None => vec![],
1879 }),
1880 _ => return None,
1881 })
1882}
1883
1884#[cfg(feature = "analyzer")]
1885fn parse_value_from_expr(expr: &syn::Expr) -> crate::ast::Value {
1886 use crate::ast::Value;
1887
1888 match expr {
1889 syn::Expr::Lit(lit) => match &lit.lit {
1890 syn::Lit::Bool(b) => Value::Bool(b.value),
1891 syn::Lit::Int(i) => i
1892 .base10_parse::<i64>()
1893 .map(Value::Int)
1894 .unwrap_or(Value::Null),
1895 syn::Lit::Float(f) => f
1896 .base10_parse::<f64>()
1897 .map(Value::Float)
1898 .unwrap_or(Value::Null),
1899 syn::Lit::Str(s) => Value::String(s.value()),
1900 _ => Value::Null,
1901 },
1902 syn::Expr::Array(arr) => {
1903 Value::Array(arr.elems.iter().map(parse_value_from_expr).collect())
1904 }
1905 syn::Expr::Reference(reference) => parse_value_from_expr(&reference.expr),
1906 syn::Expr::Paren(paren) => parse_value_from_expr(&paren.expr),
1907 syn::Expr::Group(group) => parse_value_from_expr(&group.expr),
1908 syn::Expr::MethodCall(method) if method.method == "into" => {
1909 parse_value_from_expr(&method.receiver)
1910 }
1911 syn::Expr::Call(call) => {
1912 if let Some(value) = parse_value_ctor_call(call) {
1913 return value;
1914 }
1915 let syn::Expr::Path(path_expr) = &*call.func else {
1916 return Value::Null;
1917 };
1918 let tail = path_expr
1919 .path
1920 .segments
1921 .last()
1922 .map(|s| s.ident.to_string())
1923 .unwrap_or_default();
1924 if tail == "Some" {
1925 return call
1926 .args
1927 .first()
1928 .map(parse_value_from_expr)
1929 .unwrap_or(Value::Null);
1930 }
1931 Value::Null
1932 }
1933 syn::Expr::Path(_path_expr) => Value::Null,
1934 _ => Value::Null,
1935 }
1936}
1937
1938#[cfg(feature = "analyzer")]
1939fn parse_expr_node(expr: &syn::Expr) -> Option<crate::ast::Expr> {
1940 match expr {
1941 syn::Expr::Lit(lit) => match &lit.lit {
1942 syn::Lit::Str(s) => Some(crate::ast::Expr::Named(s.value())),
1943 _ => None,
1944 },
1945 syn::Expr::Reference(reference) => parse_expr_node(&reference.expr),
1946 syn::Expr::Paren(paren) => parse_expr_node(&paren.expr),
1947 syn::Expr::Group(group) => parse_expr_node(&group.expr),
1948 syn::Expr::MethodCall(method) if method.method == "into" => {
1949 parse_expr_node(&method.receiver)
1950 }
1951 syn::Expr::Call(call) => {
1952 let syn::Expr::Path(path_expr) = &*call.func else {
1953 return None;
1954 };
1955 let segments: Vec<String> = path_expr
1956 .path
1957 .segments
1958 .iter()
1959 .map(|s| s.ident.to_string())
1960 .collect();
1961 let tail = segments.last()?.as_str();
1962 if tail == "Named" && segments.len() >= 2 && segments[segments.len() - 2] == "Expr" {
1963 return call
1964 .args
1965 .first()
1966 .and_then(parse_string_from_expr)
1967 .map(crate::ast::Expr::Named);
1968 }
1969 if tail == "Raw" && segments.len() >= 2 && segments[segments.len() - 2] == "Expr" {
1970 return call
1971 .args
1972 .first()
1973 .and_then(parse_string_from_expr)
1974 .map(crate::ast::Expr::Raw);
1975 }
1976 if tail == "col" {
1977 return call
1978 .args
1979 .first()
1980 .and_then(parse_string_from_expr)
1981 .map(crate::ast::Expr::Named);
1982 }
1983 None
1984 }
1985 _ => None,
1986 }
1987}
1988
1989#[cfg(feature = "analyzer")]
1990fn parse_condition_from_expr(expr: &syn::Expr) -> Option<crate::ast::Condition> {
1991 let syn::Expr::Struct(cond_struct) = expr else {
1992 return None;
1993 };
1994 let struct_name = cond_struct.path.segments.last()?.ident.to_string();
1995 if struct_name != "Condition" {
1996 return None;
1997 }
1998
1999 let mut left = None;
2000 let mut op = None;
2001 let mut value = None;
2002 let mut is_array_unnest = false;
2003
2004 for field in &cond_struct.fields {
2005 let syn::Member::Named(name) = &field.member else {
2006 continue;
2007 };
2008 match name.to_string().as_str() {
2009 "left" => left = parse_expr_node(&field.expr),
2010 "op" => op = parse_operator_from_expr(&field.expr),
2011 "value" => value = Some(parse_value_from_expr(&field.expr)),
2012 "is_array_unnest" => {
2013 if let syn::Expr::Lit(lit) = &field.expr
2014 && let syn::Lit::Bool(v) = &lit.lit
2015 {
2016 is_array_unnest = v.value;
2017 }
2018 }
2019 _ => {}
2020 }
2021 }
2022
2023 Some(crate::ast::Condition {
2024 left: left?,
2025 op: op?,
2026 value: value.unwrap_or(crate::ast::Value::Null),
2027 is_array_unnest,
2028 })
2029}
2030
2031#[cfg(feature = "analyzer")]
2032fn parse_condition_list(expr: &syn::Expr) -> Vec<crate::ast::Condition> {
2033 match expr {
2034 syn::Expr::Array(arr) => arr
2035 .elems
2036 .iter()
2037 .filter_map(parse_condition_from_expr)
2038 .collect(),
2039 syn::Expr::Reference(reference) => parse_condition_list(&reference.expr),
2040 syn::Expr::Paren(paren) => parse_condition_list(&paren.expr),
2041 syn::Expr::Group(group) => parse_condition_list(&group.expr),
2042 syn::Expr::Macro(mac) if mac.mac.path.is_ident("vec") => {
2043 if let Ok(arr) = syn::parse2::<syn::ExprArray>(mac.mac.tokens.clone()) {
2044 return arr
2045 .elems
2046 .iter()
2047 .filter_map(parse_condition_from_expr)
2048 .collect();
2049 }
2050 Vec::new()
2051 }
2052 _ => parse_condition_from_expr(expr).into_iter().collect(),
2053 }
2054}
2055
2056#[cfg(feature = "analyzer")]
2057fn push_filter_condition(cmd: &mut crate::ast::Qail, condition: crate::ast::Condition) {
2058 if let Some(cage) = cmd
2059 .cages
2060 .iter_mut()
2061 .find(|c| matches!(c.kind, crate::ast::CageKind::Filter))
2062 {
2063 cage.conditions.push(condition);
2064 } else {
2065 cmd.cages.push(crate::ast::Cage {
2066 kind: crate::ast::CageKind::Filter,
2067 conditions: vec![condition],
2068 logical_op: crate::ast::LogicalOp::And,
2069 });
2070 }
2071}
2072
2073#[cfg(feature = "analyzer")]
2074fn push_payload_condition(cmd: &mut crate::ast::Qail, condition: crate::ast::Condition) {
2075 if let Some(cage) = cmd
2076 .cages
2077 .iter_mut()
2078 .find(|c| matches!(c.kind, crate::ast::CageKind::Payload))
2079 {
2080 cage.conditions.push(condition);
2081 } else {
2082 cmd.cages.push(crate::ast::Cage {
2083 kind: crate::ast::CageKind::Payload,
2084 conditions: vec![condition],
2085 logical_op: crate::ast::LogicalOp::And,
2086 });
2087 }
2088}
2089
2090#[cfg(feature = "analyzer")]
2091fn normalize_join_table(table: &str) -> String {
2092 table.split_whitespace().next().unwrap_or(table).to_string()
2093}
2094
2095#[cfg(feature = "analyzer")]
2096fn apply_syn_method_step(
2097 cmd: &mut crate::ast::Qail,
2098 method: &str,
2099 args: &[syn::Expr],
2100 has_rls: &mut bool,
2101) {
2102 use crate::ast::{Condition, Expr, Join, JoinKind, Operator, SortOrder, Value};
2103
2104 match method {
2105 "with_rls" | "rls" => {
2106 *has_rls = true;
2107 }
2108 "column" => {
2109 if let Some(col) = args.first().and_then(parse_string_from_expr) {
2110 cmd.columns.push(Expr::Named(col));
2111 }
2112 }
2113 "columns" => {
2114 if let Some(arg) = args.first() {
2115 cmd.columns.extend(
2116 parse_string_list_from_expr(arg)
2117 .into_iter()
2118 .map(Expr::Named),
2119 );
2120 }
2121 }
2122 "returning" => {
2123 if let Some(arg) = args.first() {
2124 let cols: Vec<Expr> = parse_string_list_from_expr(arg)
2125 .into_iter()
2126 .map(Expr::Named)
2127 .collect();
2128 if !cols.is_empty() {
2129 match &mut cmd.returning {
2130 Some(existing) => existing.extend(cols),
2131 None => cmd.returning = Some(cols),
2132 }
2133 }
2134 }
2135 }
2136 "returning_all" => {
2137 cmd.returning = Some(vec![Expr::Star]);
2138 }
2139 "filter" => {
2140 if args.len() >= 3
2141 && let Some(column) = parse_string_from_expr(&args[0])
2142 {
2143 let op = parse_operator_from_expr(&args[1]).unwrap_or(Operator::Eq);
2144 let value = parse_value_from_expr(&args[2]);
2145 push_filter_condition(
2146 cmd,
2147 Condition {
2148 left: Expr::Named(column),
2149 op,
2150 value,
2151 is_array_unnest: false,
2152 },
2153 );
2154 }
2155 }
2156 "where_eq" | "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like" | "ilike" | "in_vals"
2157 | "is_null" | "is_not_null" => {
2158 if let Some(column) = args.first().and_then(parse_string_from_expr) {
2159 let (op, value) = match method {
2160 "where_eq" | "eq" => (
2161 Operator::Eq,
2162 args.get(1)
2163 .map(parse_value_from_expr)
2164 .unwrap_or(Value::Null),
2165 ),
2166 "ne" => (
2167 Operator::Ne,
2168 args.get(1)
2169 .map(parse_value_from_expr)
2170 .unwrap_or(Value::Null),
2171 ),
2172 "gt" => (
2173 Operator::Gt,
2174 args.get(1)
2175 .map(parse_value_from_expr)
2176 .unwrap_or(Value::Null),
2177 ),
2178 "gte" => (
2179 Operator::Gte,
2180 args.get(1)
2181 .map(parse_value_from_expr)
2182 .unwrap_or(Value::Null),
2183 ),
2184 "lt" => (
2185 Operator::Lt,
2186 args.get(1)
2187 .map(parse_value_from_expr)
2188 .unwrap_or(Value::Null),
2189 ),
2190 "lte" => (
2191 Operator::Lte,
2192 args.get(1)
2193 .map(parse_value_from_expr)
2194 .unwrap_or(Value::Null),
2195 ),
2196 "like" => (
2197 Operator::Like,
2198 args.get(1)
2199 .map(parse_value_from_expr)
2200 .unwrap_or(Value::Null),
2201 ),
2202 "ilike" => (
2203 Operator::ILike,
2204 args.get(1)
2205 .map(parse_value_from_expr)
2206 .unwrap_or(Value::Null),
2207 ),
2208 "in_vals" => (
2209 Operator::In,
2210 args.get(1)
2211 .map(parse_value_from_expr)
2212 .unwrap_or(Value::Array(vec![])),
2213 ),
2214 "is_null" => (Operator::IsNull, Value::Null),
2215 "is_not_null" => (Operator::IsNotNull, Value::Null),
2216 _ => (Operator::Eq, Value::Null),
2217 };
2218
2219 push_filter_condition(
2220 cmd,
2221 Condition {
2222 left: Expr::Named(column),
2223 op,
2224 value,
2225 is_array_unnest: false,
2226 },
2227 );
2228 }
2229 }
2230 "order_by" => {
2231 if let Some(column) = args.first().and_then(parse_string_from_expr) {
2232 let order = args
2233 .get(1)
2234 .and_then(parse_sort_order_from_expr)
2235 .unwrap_or(SortOrder::Asc);
2236 cmd.cages.push(crate::ast::Cage {
2237 kind: crate::ast::CageKind::Sort(order),
2238 conditions: vec![Condition {
2239 left: Expr::Named(column),
2240 op: Operator::Eq,
2241 value: Value::Null,
2242 is_array_unnest: false,
2243 }],
2244 logical_op: crate::ast::LogicalOp::And,
2245 });
2246 }
2247 }
2248 "order_desc" | "order_asc" => {
2249 if let Some(column) = args.first().and_then(parse_string_from_expr) {
2250 let order = if method == "order_desc" {
2251 SortOrder::Desc
2252 } else {
2253 SortOrder::Asc
2254 };
2255 cmd.cages.push(crate::ast::Cage {
2256 kind: crate::ast::CageKind::Sort(order),
2257 conditions: vec![Condition {
2258 left: Expr::Named(column),
2259 op: Operator::Eq,
2260 value: Value::Null,
2261 is_array_unnest: false,
2262 }],
2263 logical_op: crate::ast::LogicalOp::And,
2264 });
2265 }
2266 }
2267 "group_by" => {
2268 if let Some(arg) = args.first() {
2269 let cols = parse_string_list_from_expr(arg);
2270 if !cols.is_empty() {
2271 cmd.cages.push(crate::ast::Cage {
2272 kind: crate::ast::CageKind::Partition,
2273 conditions: cols
2274 .into_iter()
2275 .map(|c| Condition {
2276 left: Expr::Named(c),
2277 op: Operator::Eq,
2278 value: Value::Null,
2279 is_array_unnest: false,
2280 })
2281 .collect(),
2282 logical_op: crate::ast::LogicalOp::And,
2283 });
2284 }
2285 }
2286 }
2287 "having_cond" => {
2288 if let Some(arg) = args.first()
2289 && let Some(condition) = parse_condition_from_expr(arg)
2290 {
2291 cmd.having.push(condition);
2292 }
2293 }
2294 "having_conds" => {
2295 if let Some(arg) = args.first() {
2296 cmd.having.extend(parse_condition_list(arg));
2297 }
2298 }
2299 "join" => {
2300 if args.len() >= 4
2301 && let Some(table) = args.get(1).and_then(parse_table_name_from_expr)
2302 {
2303 let kind = args
2304 .first()
2305 .and_then(parse_join_kind_from_expr)
2306 .unwrap_or(JoinKind::Left);
2307 let on = match (
2308 args.get(2).and_then(parse_string_from_expr),
2309 args.get(3).and_then(parse_string_from_expr),
2310 ) {
2311 (Some(left_col), Some(right_col)) => Some(vec![Condition {
2312 left: Expr::Named(left_col),
2313 op: Operator::Eq,
2314 value: Value::Column(right_col),
2315 is_array_unnest: false,
2316 }]),
2317 _ => None,
2318 };
2319 cmd.joins.push(Join {
2320 kind,
2321 table: normalize_join_table(&table),
2322 on,
2323 on_true: false,
2324 });
2325 }
2326 }
2327 "left_join" | "inner_join" | "right_join" | "full_join" => {
2328 if args.len() >= 3
2329 && let Some(table) = args.first().and_then(parse_table_name_from_expr)
2330 {
2331 let kind = match method {
2332 "inner_join" => JoinKind::Inner,
2333 "right_join" => JoinKind::Right,
2334 "full_join" => JoinKind::Full,
2335 _ => JoinKind::Left,
2336 };
2337 let on = match (
2338 args.get(1).and_then(parse_string_from_expr),
2339 args.get(2).and_then(parse_string_from_expr),
2340 ) {
2341 (Some(left_col), Some(right_col)) => Some(vec![Condition {
2342 left: Expr::Named(left_col),
2343 op: Operator::Eq,
2344 value: Value::Column(right_col),
2345 is_array_unnest: false,
2346 }]),
2347 _ => None,
2348 };
2349 cmd.joins.push(Join {
2350 kind,
2351 table: normalize_join_table(&table),
2352 on,
2353 on_true: false,
2354 });
2355 }
2356 }
2357 "join_on" | "join_on_optional" => {
2358 if let Some(table) = args.first().and_then(parse_table_name_from_expr) {
2359 cmd.joins.push(Join {
2360 kind: JoinKind::Left,
2361 table: normalize_join_table(&table),
2362 on: None,
2363 on_true: false,
2364 });
2365 }
2366 }
2367 "left_join_as" | "inner_join_as" => {
2368 if args.len() >= 4
2369 && let Some(table) = args.first().and_then(parse_table_name_from_expr)
2370 {
2371 let kind = if method == "inner_join_as" {
2372 JoinKind::Inner
2373 } else {
2374 JoinKind::Left
2375 };
2376 let on = match (
2377 args.get(2).and_then(parse_string_from_expr),
2378 args.get(3).and_then(parse_string_from_expr),
2379 ) {
2380 (Some(left_col), Some(right_col)) => Some(vec![Condition {
2381 left: Expr::Named(left_col),
2382 op: Operator::Eq,
2383 value: Value::Column(right_col),
2384 is_array_unnest: false,
2385 }]),
2386 _ => None,
2387 };
2388 cmd.joins.push(Join {
2389 kind,
2390 table: normalize_join_table(&table),
2391 on,
2392 on_true: false,
2393 });
2394 }
2395 }
2396 "join_conds" | "left_join_conds" | "inner_join_conds" => {
2397 let (kind, table_idx, cond_idx) = match method {
2398 "join_conds" => (
2399 args.first()
2400 .and_then(parse_join_kind_from_expr)
2401 .unwrap_or(JoinKind::Left),
2402 1,
2403 2,
2404 ),
2405 "inner_join_conds" => (JoinKind::Inner, 0, 1),
2406 _ => (JoinKind::Left, 0, 1),
2407 };
2408
2409 if let Some(table_expr) = args.get(table_idx)
2410 && let Some(table) = parse_table_name_from_expr(table_expr)
2411 {
2412 let conditions = args
2413 .get(cond_idx)
2414 .map(parse_condition_list)
2415 .unwrap_or_default();
2416 cmd.joins.push(Join {
2417 kind,
2418 table: normalize_join_table(&table),
2419 on: if conditions.is_empty() {
2420 None
2421 } else {
2422 Some(conditions)
2423 },
2424 on_true: false,
2425 });
2426 }
2427 }
2428 "set_value" | "set_coalesce" | "set_coalesce_opt" => {
2429 if let Some(column) = args.first().and_then(parse_string_from_expr) {
2430 let value = args
2431 .get(1)
2432 .map(parse_value_from_expr)
2433 .unwrap_or(Value::Null);
2434 push_payload_condition(
2435 cmd,
2436 Condition {
2437 left: Expr::Named(column),
2438 op: Operator::Eq,
2439 value,
2440 is_array_unnest: false,
2441 },
2442 );
2443 }
2444 }
2445 _ => {}
2446 }
2447}
2448
2449#[cfg(feature = "analyzer")]
2450fn syn_cmd_score(cmd: &crate::ast::Qail, has_rls: bool) -> usize {
2451 let group_cols = cmd
2452 .cages
2453 .iter()
2454 .filter(|c| matches!(c.kind, crate::ast::CageKind::Partition))
2455 .map(|c| c.conditions.len())
2456 .sum::<usize>();
2457 let filter_cols = cmd
2458 .cages
2459 .iter()
2460 .filter(|c| matches!(c.kind, crate::ast::CageKind::Filter))
2461 .map(|c| c.conditions.len())
2462 .sum::<usize>();
2463
2464 cmd.columns.len()
2465 + (cmd.joins.len() * 8)
2466 + (group_cols * 5)
2467 + (cmd.having.len() * 6)
2468 + filter_cols
2469 + cmd.returning.as_ref().map_or(0, |r| r.len() * 2)
2470 + usize::from(has_rls)
2471}
2472
2473pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
2476 use crate::ast::Qail;
2477 use crate::validator::Validator;
2478
2479 let mut validator = Validator::new();
2481 for (table_name, table_schema) in &schema.tables {
2482 let type_strings: Vec<(String, String)> = table_schema
2484 .columns
2485 .iter()
2486 .map(|(name, typ)| (name.clone(), typ.to_pg_type()))
2487 .collect();
2488 let cols_with_types: Vec<(&str, &str)> = type_strings
2489 .iter()
2490 .map(|(name, typ)| (name.as_str(), typ.as_str()))
2491 .collect();
2492 validator.add_table_with_types(table_name, &cols_with_types);
2493 }
2494
2495 let mut errors = Vec::new();
2496 let mut rls_warnings = Vec::new();
2497 #[cfg(feature = "analyzer")]
2498 let syn_usage_index = build_syn_usage_index(usages);
2499
2500 for usage in usages {
2501 if usage.is_cte_ref && !schema.has_table(&usage.table) {
2505 continue;
2506 }
2507
2508 let action = usage_action_to_ast(&usage.action);
2510
2511 let mut cmd = Qail {
2512 action,
2513 table: usage.table.clone(),
2514 ..Default::default()
2515 };
2516 #[allow(unused_mut)]
2517 let mut has_rls = usage.has_rls;
2518
2519 #[cfg(feature = "analyzer")]
2520 if let Some(parsed) = syn_usage_index.get(&syn_usage_key(
2521 &usage.file,
2522 usage.line,
2523 &usage.action,
2524 &usage.table,
2525 )) {
2526 cmd = parsed.cmd.clone();
2527 has_rls |= parsed.has_rls;
2528 }
2529
2530 append_scanned_columns(&mut cmd, &usage.columns);
2532
2533 match validator.validate_command(&cmd) {
2535 Ok(()) => {}
2536 Err(validation_errors) => {
2537 for e in validation_errors {
2538 errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
2539 }
2540 }
2541 }
2542
2543 if schema.is_rls_table(&usage.table) && !has_rls {
2545 rls_warnings.push(format!(
2546 "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
2547 usage.file, usage.line, usage.action.to_lowercase(), usage.table
2548 ));
2549 }
2550 }
2551
2552 errors.extend(rls_warnings);
2555
2556 errors
2557}
2558
2559#[cfg(feature = "analyzer")]
2565fn run_nplus1_check(src_dir: &str) {
2566 use crate::analyzer::{NPlusOneSeverity, detect_n_plus_one_in_dir};
2567
2568 println!("cargo:rerun-if-env-changed=QAIL_NPLUS1");
2569 println!("cargo:rerun-if-env-changed=QAIL_NPLUS1_MAX_WARNINGS");
2570
2571 let mode = std::env::var("QAIL_NPLUS1").unwrap_or_else(|_| "warn".to_string());
2572
2573 if mode == "off" || mode == "false" || mode == "0" {
2574 return;
2575 }
2576
2577 let max_warnings: usize = std::env::var("QAIL_NPLUS1_MAX_WARNINGS")
2578 .ok()
2579 .and_then(|s| s.parse().ok())
2580 .unwrap_or(50);
2581
2582 let diagnostics = detect_n_plus_one_in_dir(Path::new(src_dir));
2583
2584 if diagnostics.is_empty() {
2585 println!("cargo:warning=QAIL: N+1 scan clean ✓");
2586 return;
2587 }
2588
2589 let total = diagnostics.len();
2590 let shown = total.min(max_warnings);
2591
2592 for diag in diagnostics.iter().take(shown) {
2593 let prefix = match diag.severity {
2594 NPlusOneSeverity::Error => "QAIL N+1 ERROR",
2595 NPlusOneSeverity::Warning => "QAIL N+1",
2596 };
2597 println!("cargo:warning={}: {}", prefix, diag);
2598 }
2599
2600 if total > shown {
2601 println!(
2602 "cargo:warning=QAIL N+1: ... and {} more (set QAIL_NPLUS1_MAX_WARNINGS to see all)",
2603 total - shown
2604 );
2605 }
2606
2607 if mode == "deny" {
2608 panic!(
2611 "QAIL N+1: {} diagnostic(s) found. Fix N+1 patterns or set QAIL_NPLUS1=warn",
2612 total
2613 );
2614 }
2615}
2616
2617#[cfg(not(feature = "analyzer"))]
2618fn run_nplus1_check(_src_dir: &str) {
2619 }
2621
2622pub fn validate() {
2628 let mode = std::env::var("QAIL").unwrap_or_else(|_| {
2629 if Path::new("schema.qail").exists() || Path::new("schema").is_dir() {
2630 "schema".to_string()
2631 } else {
2632 "false".to_string()
2633 }
2634 });
2635
2636 match mode.as_str() {
2637 "schema" => {
2638 if let Ok(source) = crate::schema_source::resolve_schema_source("schema.qail") {
2639 for path in source.watch_paths() {
2640 println!("cargo:rerun-if-changed={}", path.display());
2641 }
2642 } else {
2643 println!("cargo:rerun-if-changed=schema.qail");
2646 println!("cargo:rerun-if-changed=schema");
2647 }
2648 println!("cargo:rerun-if-changed=migrations");
2649 println!("cargo:rerun-if-changed=src");
2650 println!("cargo:rerun-if-env-changed=QAIL");
2651
2652 match Schema::parse_file("schema.qail") {
2653 Ok(mut schema) => {
2654 let merged = match schema.merge_migrations("migrations") {
2656 Ok(n) => n,
2657 Err(e) => {
2658 println!("cargo:warning=QAIL: Migration merge failed: {}", e);
2659 0
2660 }
2661 };
2662 if merged > 0 {
2663 println!(
2664 "cargo:warning=QAIL: Merged {} schema changes from migrations",
2665 merged
2666 );
2667 }
2668
2669 let usages = scan_source_files("src/");
2670 let all_results = validate_against_schema(&schema, &usages);
2671
2672 let schema_errors: Vec<_> = all_results
2674 .iter()
2675 .filter(|e| !e.contains("RLS AUDIT"))
2676 .collect();
2677 let rls_warnings: Vec<_> = all_results
2678 .iter()
2679 .filter(|e| e.contains("RLS AUDIT"))
2680 .collect();
2681
2682 for w in &rls_warnings {
2684 println!("cargo:warning=QAIL RLS: {}", w);
2685 }
2686
2687 if schema_errors.is_empty() {
2688 println!(
2689 "cargo:warning=QAIL: Validated {} queries against schema source ✓",
2690 usages.len()
2691 );
2692 } else {
2693 for error in &schema_errors {
2694 println!("cargo:warning=QAIL ERROR: {}", error);
2695 }
2696 panic!("QAIL validation failed with {} errors", schema_errors.len());
2698 }
2699
2700 run_nplus1_check("src");
2702 }
2703 Err(e) => {
2704 panic!("QAIL: Failed to parse schema source: {}", e);
2705 }
2706 }
2707 }
2708 "live" => {
2709 println!("cargo:rerun-if-env-changed=QAIL");
2710 println!("cargo:rerun-if-env-changed=DATABASE_URL");
2711 println!("cargo:rerun-if-changed=src");
2712
2713 let db_url = match std::env::var("DATABASE_URL") {
2715 Ok(url) => url,
2716 Err(_) => {
2717 panic!("QAIL=live requires DATABASE_URL environment variable");
2718 }
2719 };
2720
2721 println!("cargo:warning=QAIL: Pulling schema from live database...");
2723
2724 let pull_result = std::process::Command::new("qail")
2725 .args(["pull", &db_url])
2726 .output();
2727
2728 match pull_result {
2729 Ok(output) => {
2730 if !output.status.success() {
2731 let stderr = String::from_utf8_lossy(&output.stderr);
2732 panic!("QAIL: Failed to pull schema: {}", stderr);
2733 }
2734 println!("cargo:warning=QAIL: Schema pulled successfully ✓");
2735 }
2736 Err(e) => {
2737 println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
2739
2740 let cargo_result = std::process::Command::new("cargo")
2741 .args(["run", "-p", "qail", "--", "pull", &db_url])
2742 .current_dir(
2743 std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()),
2744 )
2745 .output();
2746
2747 match cargo_result {
2748 Ok(output) if output.status.success() => {
2749 println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
2750 }
2751 _ => {
2752 panic!(
2753 "QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema",
2754 e
2755 );
2756 }
2757 }
2758 }
2759 }
2760
2761 match Schema::parse_file("schema.qail") {
2763 Ok(mut schema) => {
2764 let merged = match schema.merge_migrations("migrations") {
2766 Ok(n) => n,
2767 Err(e) => {
2768 println!("cargo:warning=QAIL: Migration merge failed: {}", e);
2769 0
2770 }
2771 };
2772 if merged > 0 {
2773 println!(
2774 "cargo:warning=QAIL: Merged {} schema changes from pending migrations",
2775 merged
2776 );
2777 }
2778
2779 let usages = scan_source_files("src/");
2780 let all_results = validate_against_schema(&schema, &usages);
2781
2782 let schema_errors: Vec<_> = all_results
2784 .iter()
2785 .filter(|e| !e.contains("RLS AUDIT"))
2786 .collect();
2787 let rls_warnings: Vec<_> = all_results
2788 .iter()
2789 .filter(|e| e.contains("RLS AUDIT"))
2790 .collect();
2791
2792 for w in &rls_warnings {
2794 println!("cargo:warning=QAIL RLS: {}", w);
2795 }
2796
2797 if schema_errors.is_empty() {
2798 println!(
2799 "cargo:warning=QAIL: Validated {} queries against live database ✓",
2800 usages.len()
2801 );
2802 } else {
2803 for error in &schema_errors {
2804 println!("cargo:warning=QAIL ERROR: {}", error);
2805 }
2806 panic!("QAIL validation failed with {} errors", schema_errors.len());
2807 }
2808
2809 run_nplus1_check("src");
2811 }
2812 Err(e) => {
2813 panic!("QAIL: Failed to parse schema after pull: {}", e);
2814 }
2815 }
2816 }
2817 "false" | "off" | "0" => {
2818 println!("cargo:rerun-if-env-changed=QAIL");
2819 }
2821 _ => {
2822 panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
2823 }
2824 }
2825}
2826
2827#[cfg(test)]
2828mod tests {
2829 use super::*;
2830
2831 #[test]
2832 fn test_parse_schema() {
2833 let content = r#"
2835# Test schema
2836
2837table users {
2838 id UUID primary_key
2839 name TEXT not_null
2840 email TEXT unique
2841}
2842
2843table posts {
2844 id UUID
2845 user_id UUID
2846 title TEXT
2847}
2848"#;
2849 let schema = Schema::parse(content).unwrap();
2850 assert!(schema.has_table("users"));
2851 assert!(schema.has_table("posts"));
2852 assert!(schema.table("users").unwrap().has_column("id"));
2853 assert!(schema.table("users").unwrap().has_column("name"));
2854 assert!(!schema.table("users").unwrap().has_column("foo"));
2855 }
2856
2857 #[test]
2858 fn test_extract_string_arg() {
2859 assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
2860 assert_eq!(
2861 extract_string_arg(r#""table_name")"#),
2862 Some("table_name".to_string())
2863 );
2864 }
2865
2866 #[test]
2867 fn test_scan_file() {
2868 let content = r#"
2870let query = Qail::get("users").column("id").column("name").eq("active", true);
2871"#;
2872 let mut usages = Vec::new();
2873 scan_file("test.rs", content, &mut usages);
2874
2875 assert_eq!(usages.len(), 1);
2876 assert_eq!(usages[0].table, "users");
2877 assert_eq!(usages[0].action, "GET");
2878 assert!(usages[0].columns.contains(&"id".to_string()));
2879 assert!(usages[0].columns.contains(&"name".to_string()));
2880 }
2881
2882 #[test]
2883 fn test_scan_file_multiline() {
2884 let content = r#"
2886let query = Qail::get("posts")
2887 .column("id")
2888 .column("title")
2889 .column("author")
2890 .eq("published", true)
2891 .order_by("created_at", Desc);
2892"#;
2893 let mut usages = Vec::new();
2894 scan_file("test.rs", content, &mut usages);
2895
2896 assert_eq!(usages.len(), 1);
2897 assert_eq!(usages[0].table, "posts");
2898 assert_eq!(usages[0].action, "GET");
2899 assert!(usages[0].columns.contains(&"id".to_string()));
2900 assert!(usages[0].columns.contains(&"title".to_string()));
2901 assert!(usages[0].columns.contains(&"author".to_string()));
2902 }
2903
2904 #[test]
2905 fn test_scan_typed_api() {
2906 let content = r#"
2907let q = Qail::typed(users::table).column("email");
2908"#;
2909 let mut usages = Vec::new();
2910 scan_file("test.rs", content, &mut usages);
2911
2912 assert_eq!(usages.len(), 1);
2913 assert_eq!(usages[0].table, "users");
2914 assert_eq!(usages[0].action, "TYPED");
2915 assert!(usages[0].columns.contains(&"email".to_string()));
2916 }
2917
2918 #[test]
2919 fn test_scan_raw_sql_not_validated() {
2920 let content = r#"
2921let q = Qail::raw_sql("SELECT * FROM users");
2922"#;
2923 let mut usages = Vec::new();
2924 scan_file("test.rs", content, &mut usages);
2925 assert_eq!(usages.len(), 0);
2927 }
2928
2929 #[test]
2930 fn test_extract_columns_is_null() {
2931 let line = r#"Qail::get("t").is_null("deleted_at").is_not_null("name")"#;
2932 let cols = extract_columns(line);
2933 assert!(cols.contains(&"deleted_at".to_string()));
2934 assert!(cols.contains(&"name".to_string()));
2935 }
2936
2937 #[test]
2938 fn test_extract_columns_set_value() {
2939 let line =
2940 r#"Qail::set("orders").set_value("status", "Paid").set_coalesce("notes", "default")"#;
2941 let cols = extract_columns(line);
2942 assert!(cols.contains(&"status".to_string()));
2943 assert!(cols.contains(&"notes".to_string()));
2944 }
2945
2946 #[test]
2947 fn test_extract_columns_returning() {
2948 let line = r#"Qail::add("orders").returning(["id", "status"])"#;
2949 let cols = extract_columns(line);
2950 assert!(cols.contains(&"id".to_string()));
2951 assert!(cols.contains(&"status".to_string()));
2952 }
2953
2954 #[test]
2955 fn test_extract_columns_on_conflict() {
2956 let line = r#"Qail::put("t").on_conflict_update(&["id"], &[("name", Expr::Named("excluded.name".into()))])"#;
2957 let cols = extract_columns(line);
2958 assert!(cols.contains(&"id".to_string()));
2959 }
2960
2961 #[test]
2962 fn test_validate_against_schema_casted_column_no_false_positive() {
2963 let schema = Schema::parse(
2964 r#"
2965table users {
2966 id TEXT
2967}
2968"#,
2969 )
2970 .unwrap();
2971
2972 let content = r#"
2973let q = Qail::get("users").eq("id::text", "abc");
2974"#;
2975
2976 let mut usages = Vec::new();
2977 scan_file("test.rs", content, &mut usages);
2978 let errors = validate_against_schema(&schema, &usages);
2979 assert!(
2980 errors.is_empty(),
2981 "casted column should not produce schema error: {:?}",
2982 errors
2983 );
2984 }
2985
2986 #[test]
2987 fn test_cte_cross_chain_detection() {
2988 let content = r#"
2991let cte = Qail::get("orders").columns(["total"]).to_cte("agg");
2992let q = Qail::get("agg").columns(["total"]);
2993"#;
2994 let mut usages = Vec::new();
2995 scan_file("test.rs", content, &mut usages);
2996
2997 assert_eq!(usages.len(), 2);
2998 assert_eq!(usages[0].table, "orders");
3000 assert!(!usages[0].is_cte_ref);
3001 assert_eq!(usages[1].table, "agg");
3003 assert!(usages[1].is_cte_ref);
3004 }
3005
3006 #[test]
3007 fn test_cte_with_inline_detection() {
3008 let content = r#"
3010let q = Qail::get("results").with("agg", Qail::get("orders"));
3011"#;
3012 let mut usages = Vec::new();
3013 scan_file("test.rs", content, &mut usages);
3014
3015 assert_eq!(usages.len(), 1);
3017 assert!(!usages[0].is_cte_ref);
3019 }
3020
3021 #[test]
3022 fn test_rls_detection_typed_api() {
3023 let content = r#"
3025let q = Qail::get("orders")
3026 .columns(["id"])
3027 .rls(&ctx);
3028"#;
3029 let mut usages = Vec::new();
3030 scan_file("test.rs", content, &mut usages);
3031
3032 assert_eq!(usages.len(), 1);
3033 assert!(usages[0].has_rls);
3034 }
3035
3036 #[test]
3037 fn test_rls_detection_with_rls() {
3038 let content = r#"
3039let q = Qail::get("orders")
3040 .columns(["id"])
3041 .with_rls(&ctx);
3042"#;
3043 let mut usages = Vec::new();
3044 scan_file("test.rs", content, &mut usages);
3045
3046 assert_eq!(usages.len(), 1);
3047 assert!(usages[0].has_rls);
3048 }
3049
3050 #[test]
3051 fn test_extract_typed_table_arg() {
3052 assert_eq!(
3053 extract_typed_table_arg("users::table)"),
3054 Some("users".to_string())
3055 );
3056 assert_eq!(
3057 extract_typed_table_arg("users::Users)"),
3058 Some("users".to_string())
3059 );
3060 assert_eq!(
3061 extract_typed_table_arg("schema::users::table)"),
3062 Some("users".to_string())
3063 );
3064 assert_eq!(
3065 extract_typed_table_arg("Orders)"),
3066 Some("orders".to_string())
3067 );
3068 assert_eq!(extract_typed_table_arg(""), None);
3069 }
3070
3071 #[cfg(feature = "analyzer")]
3072 #[test]
3073 fn test_syn_extract_join_group_by_having() {
3074 let source = r#"
3075fn demo(ctx: &RlsContext) {
3076 let _q = Qail::get("orders")
3077 .left_join("customers", "orders.customer_id", "customers.id")
3078 .group_by(["customer_id"])
3079 .having_cond(Condition {
3080 left: Expr::Named("total".into()),
3081 op: Operator::Eq,
3082 value: Value::Int(1),
3083 is_array_unnest: false,
3084 })
3085 .with_rls(ctx);
3086}
3087"#;
3088
3089 let parsed = extract_syn_usages_from_source(source);
3090 let usage = parsed
3091 .into_iter()
3092 .find(|u| u.action == "GET" && u.table == "orders")
3093 .expect("expected syn usage for Qail::get(\"orders\")");
3094
3095 assert_eq!(usage.cmd.joins.len(), 1);
3096 assert!(
3097 usage
3098 .cmd
3099 .cages
3100 .iter()
3101 .any(|c| matches!(c.kind, crate::ast::CageKind::Partition))
3102 );
3103 assert_eq!(usage.cmd.having.len(), 1);
3104 assert!(usage.has_rls);
3105 }
3106
3107 #[cfg(feature = "analyzer")]
3108 #[test]
3109 fn test_validate_against_schema_uses_syn_structural_fields() {
3110 let schema = Schema::parse(
3111 r#"
3112table orders {
3113 id INT
3114 customer_id INT
3115 total INT
3116}
3117
3118table customers {
3119 id INT
3120}
3121"#,
3122 )
3123 .unwrap();
3124
3125 let content = r#"
3126fn demo() {
3127 let _q = Qail::get("orders")
3128 .left_join("customerz", "orders.customer_id", "customerz.id")
3129 .group_by(["custmer_id"])
3130 .having_cond(Condition {
3131 left: Expr::Named("totl".into()),
3132 op: Operator::Eq,
3133 value: Value::Int(1),
3134 is_array_unnest: false,
3135 });
3136}
3137"#;
3138
3139 let unique = std::time::SystemTime::now()
3140 .duration_since(std::time::UNIX_EPOCH)
3141 .unwrap()
3142 .as_nanos();
3143 let test_path = std::env::temp_dir().join(format!(
3144 "qail_build_syn_structural_{}_{}.rs",
3145 std::process::id(),
3146 unique
3147 ));
3148 std::fs::write(&test_path, content).unwrap();
3149
3150 let mut usages = Vec::new();
3151 scan_file(&test_path.display().to_string(), content, &mut usages);
3152 let errors = validate_against_schema(&schema, &usages);
3153 let _ = std::fs::remove_file(&test_path);
3154
3155 assert!(errors.iter().any(|e| e.contains("customerz")));
3156 assert!(errors.iter().any(|e| e.contains("custmer_id")));
3157 assert!(errors.iter().any(|e| e.contains("totl")));
3158 }
3159}
3160
3161fn qail_type_to_rust(col_type: &ColumnType) -> &'static str {
3167 match col_type {
3168 ColumnType::Uuid => "uuid::Uuid",
3169 ColumnType::Text | ColumnType::Varchar(_) => "String",
3170 ColumnType::Int | ColumnType::Serial => "i32",
3171 ColumnType::BigInt | ColumnType::BigSerial => "i64",
3172 ColumnType::Bool => "bool",
3173 ColumnType::Float => "f32",
3174 ColumnType::Decimal(_) => "rust_decimal::Decimal",
3175 ColumnType::Jsonb => "serde_json::Value",
3176 ColumnType::Timestamp | ColumnType::Timestamptz => "chrono::DateTime<chrono::Utc>",
3177 ColumnType::Date => "chrono::NaiveDate",
3178 ColumnType::Time => "chrono::NaiveTime",
3179 ColumnType::Bytea => "Vec<u8>",
3180 ColumnType::Array(_) => "Vec<serde_json::Value>",
3181 ColumnType::Enum { .. } => "String",
3182 ColumnType::Range(_) => "String",
3183 ColumnType::Interval => "String",
3184 ColumnType::Cidr | ColumnType::Inet => "String",
3185 ColumnType::MacAddr => "String",
3186 }
3187}
3188
3189fn to_rust_ident(name: &str) -> String {
3191 let name = match name {
3193 "type" => "r#type",
3194 "match" => "r#match",
3195 "ref" => "r#ref",
3196 "self" => "r#self",
3197 "mod" => "r#mod",
3198 "use" => "r#use",
3199 _ => name,
3200 };
3201 name.to_string()
3202}
3203
3204fn to_struct_name(name: &str) -> String {
3206 name.chars()
3207 .next()
3208 .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
3209 .unwrap_or_default()
3210}
3211
3212pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
3228 let schema = Schema::parse_file(schema_path)?;
3229 let code = generate_schema_code(&schema);
3230
3231 fs::write(output_path, code)
3232 .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
3233
3234 Ok(())
3235}
3236
3237pub fn generate_schema_code(schema: &Schema) -> String {
3239 let mut code = String::new();
3240
3241 code.push_str("//! Auto-generated typed schema from schema.qail\n");
3243 code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
3244 code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
3245 code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
3246
3247 let mut tables: Vec<_> = schema.tables.values().collect();
3249 tables.sort_by(|a, b| a.name.cmp(&b.name));
3250
3251 for table in &tables {
3252 let mod_name = to_rust_ident(&table.name);
3253 let struct_name = to_struct_name(&table.name);
3254
3255 code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
3256 code.push_str(&format!("pub mod {} {{\n", mod_name));
3257 code.push_str(" use super::*;\n\n");
3258
3259 code.push_str(&format!(" /// Table marker for `{}`\n", table.name));
3261 code.push_str(" #[derive(Debug, Clone, Copy)]\n");
3262 code.push_str(&format!(" pub struct {};\n\n", struct_name));
3263
3264 code.push_str(&format!(" impl Table for {} {{\n", struct_name));
3265 code.push_str(&format!(
3266 " fn table_name() -> &'static str {{ \"{}\" }}\n",
3267 table.name
3268 ));
3269 code.push_str(" }\n\n");
3270
3271 code.push_str(&format!(" impl From<{}> for String {{\n", struct_name));
3272 code.push_str(&format!(
3273 " fn from(_: {}) -> String {{ \"{}\".to_string() }}\n",
3274 struct_name, table.name
3275 ));
3276 code.push_str(" }\n\n");
3277
3278 code.push_str(&format!(" impl AsRef<str> for {} {{\n", struct_name));
3279 code.push_str(&format!(
3280 " fn as_ref(&self) -> &str {{ \"{}\" }}\n",
3281 table.name
3282 ));
3283 code.push_str(" }\n\n");
3284
3285 code.push_str(&format!(" /// The `{}` table\n", table.name));
3287 code.push_str(&format!(
3288 " pub const table: {} = {};\n\n",
3289 struct_name, struct_name
3290 ));
3291
3292 let mut columns: Vec<_> = table.columns.iter().collect();
3294 columns.sort_by(|a, b| a.0.cmp(b.0));
3295
3296 for (col_name, col_type) in columns {
3298 let rust_type = qail_type_to_rust(col_type);
3299 let col_ident = to_rust_ident(col_name);
3300 let policy = table
3301 .policies
3302 .get(col_name)
3303 .map(|s| s.as_str())
3304 .unwrap_or("Public");
3305 let rust_policy = if policy == "Protected" {
3306 "Protected"
3307 } else {
3308 "Public"
3309 };
3310
3311 code.push_str(&format!(
3312 " /// Column `{}.{}` ({}) - {}\n",
3313 table.name,
3314 col_name,
3315 col_type.to_pg_type(),
3316 policy
3317 ));
3318 code.push_str(&format!(
3319 " pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
3320 col_ident, rust_type, rust_policy, table.name, col_name
3321 ));
3322 }
3323
3324 code.push_str("}\n\n");
3325 }
3326
3327 code.push_str(
3332 "// =============================================================================\n",
3333 );
3334 code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
3335 code.push_str(
3336 "// =============================================================================\n\n",
3337 );
3338
3339 for table in &tables {
3340 for fk in &table.foreign_keys {
3341 let from_mod = to_rust_ident(&table.name);
3346 let from_struct = to_struct_name(&table.name);
3347 let to_mod = to_rust_ident(&fk.ref_table);
3348 let to_struct = to_struct_name(&fk.ref_table);
3349
3350 code.push_str(&format!(
3353 "/// {} has a foreign key to {} via {}.{}\n",
3354 table.name, fk.ref_table, table.name, fk.column
3355 ));
3356 code.push_str(&format!(
3357 "impl RelatedTo<{}::{}> for {}::{} {{\n",
3358 to_mod, to_struct, from_mod, from_struct
3359 ));
3360 code.push_str(&format!(
3361 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
3362 fk.column, fk.ref_column
3363 ));
3364 code.push_str("}\n\n");
3365
3366 code.push_str(&format!(
3370 "/// {} is referenced by {} via {}.{}\n",
3371 fk.ref_table, table.name, table.name, fk.column
3372 ));
3373 code.push_str(&format!(
3374 "impl RelatedTo<{}::{}> for {}::{} {{\n",
3375 from_mod, from_struct, to_mod, to_struct
3376 ));
3377 code.push_str(&format!(
3378 " fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
3379 fk.ref_column, fk.column
3380 ));
3381 code.push_str("}\n\n");
3382 }
3383 }
3384
3385 code
3386}
3387
3388#[cfg(test)]
3389mod codegen_tests {
3390 use super::*;
3391
3392 #[test]
3393 fn test_generate_schema_code() {
3394 let schema_content = r#"
3395table users {
3396 id UUID primary_key
3397 email TEXT not_null
3398 age INT
3399}
3400
3401table posts {
3402 id UUID primary_key
3403 user_id UUID ref:users.id
3404 title TEXT
3405}
3406"#;
3407
3408 let schema = Schema::parse(schema_content).unwrap();
3409 let code = generate_schema_code(&schema);
3410
3411 assert!(code.contains("pub mod users {"));
3413 assert!(code.contains("pub mod posts {"));
3414
3415 assert!(code.contains("pub struct Users;"));
3417 assert!(code.contains("pub struct Posts;"));
3418
3419 assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
3421 assert!(code.contains("pub const email: TypedColumn<String, Public>"));
3422 assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
3423
3424 assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
3426 assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
3427 }
3428
3429 #[test]
3430 fn test_generate_protected_column() {
3431 let schema_content = r#"
3432table secrets {
3433 id UUID primary_key
3434 token TEXT protected
3435}
3436"#;
3437 let schema = Schema::parse(schema_content).unwrap();
3438 let code = generate_schema_code(&schema);
3439
3440 assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
3442 }
3443}
3444
3445#[cfg(test)]
3446mod migration_parser_tests {
3447 use super::*;
3448
3449 #[test]
3450 fn test_agent_contracts_migration_parses_all_columns() {
3451 let sql = r#"
3452CREATE TABLE agent_contracts (
3453 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3454 agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
3455 operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
3456 pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
3457 commission_percent DECIMAL(5,2),
3458 static_markup DECIMAL(10,2),
3459 is_active BOOLEAN DEFAULT true,
3460 valid_from DATE,
3461 valid_until DATE,
3462 approved_by UUID REFERENCES users(id),
3463 created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
3464 updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
3465 UNIQUE(agent_id, operator_id)
3466);
3467"#;
3468
3469 let mut schema = Schema::default();
3470 schema.parse_sql_migration(sql);
3471
3472 let table = schema
3473 .tables
3474 .get("agent_contracts")
3475 .expect("agent_contracts table should exist");
3476
3477 for col in &[
3478 "id",
3479 "agent_id",
3480 "operator_id",
3481 "pricing_model",
3482 "commission_percent",
3483 "static_markup",
3484 "is_active",
3485 "valid_from",
3486 "valid_until",
3487 "approved_by",
3488 "created_at",
3489 "updated_at",
3490 ] {
3491 assert!(
3492 table.columns.contains_key(*col),
3493 "Missing column: '{}'. Found: {:?}",
3494 col,
3495 table.columns.keys().collect::<Vec<_>>()
3496 );
3497 }
3498 }
3499
3500 #[test]
3503 fn test_keyword_prefixed_column_names_are_not_skipped() {
3504 let sql = r#"
3505CREATE TABLE edge_cases (
3506 id UUID PRIMARY KEY,
3507 created_at TIMESTAMPTZ NOT NULL,
3508 created_by UUID,
3509 primary_contact VARCHAR(255),
3510 check_status VARCHAR(20),
3511 unique_code VARCHAR(50),
3512 foreign_ref UUID,
3513 constraint_name VARCHAR(100),
3514 PRIMARY KEY (id),
3515 CHECK (check_status IN ('pending', 'active')),
3516 UNIQUE (unique_code),
3517 CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
3518);
3519"#;
3520
3521 let mut schema = Schema::default();
3522 schema.parse_sql_migration(sql);
3523
3524 let table = schema
3525 .tables
3526 .get("edge_cases")
3527 .expect("edge_cases table should exist");
3528
3529 for col in &[
3531 "created_at",
3532 "created_by",
3533 "primary_contact",
3534 "check_status",
3535 "unique_code",
3536 "foreign_ref",
3537 "constraint_name",
3538 ] {
3539 assert!(
3540 table.columns.contains_key(*col),
3541 "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
3542 col,
3543 table.columns.keys().collect::<Vec<_>>()
3544 );
3545 }
3546
3547 assert!(
3550 !table.columns.contains_key("primary"),
3551 "Constraint keyword 'PRIMARY' should not be treated as a column"
3552 );
3553 }
3554}