1use crate::validator::Validator;
21
22fn strip_schema_comments(line: &str) -> &str {
23 let line = line.split_once("--").map_or(line, |(left, _)| left);
24 line.split_once('#').map_or(line, |(left, _)| left).trim()
25}
26
27#[derive(Debug, Clone)]
29pub struct Schema {
30 pub tables: Vec<TableDef>,
32}
33
34#[derive(Debug, Clone)]
36pub struct TableDef {
37 pub name: String,
39 pub columns: Vec<ColumnDef>,
41}
42
43#[derive(Debug, Clone)]
45pub struct ColumnDef {
46 pub name: String,
48 pub typ: String,
50 pub nullable: bool,
52 pub primary_key: bool,
54}
55
56impl Schema {
57 pub fn new() -> Self {
59 Self { tables: Vec::new() }
60 }
61
62 pub fn add_table(&mut self, table: TableDef) {
64 self.tables.push(table);
65 }
66
67 pub fn to_validator(&self) -> Validator {
69 let mut v = Validator::new();
70 for table in &self.tables {
71 let cols: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
72 v.add_table(&table.name, &cols);
73 }
74 v
75 }
76
77 pub fn from_qail_schema(input: &str) -> Result<Self, String> {
86 let mut schema = Schema::new();
87 let mut current_table: Option<TableDef> = None;
88
89 for raw_line in input.lines() {
90 let line = strip_schema_comments(raw_line);
91
92 if line.is_empty() {
94 continue;
95 }
96
97 if let Some(rest) = line.strip_prefix("table ") {
99 if let Some(t) = current_table.take() {
101 schema.tables.push(t);
102 }
103
104 let name = rest
105 .trim()
106 .trim_end_matches('{')
107 .trim_end_matches('(')
108 .trim();
109 if name.is_empty() {
110 return Err(format!("Invalid table line: {}", line));
111 }
112
113 current_table = Some(TableDef::new(name));
114 }
115 else if matches!(line.trim_end_matches(';'), ")" | "}") {
117 if let Some(t) = current_table.take() {
118 schema.tables.push(t);
119 }
120 }
121 else if let Some(ref mut table) = current_table {
123 let line = line.trim_end_matches(',');
125
126 let parts: Vec<&str> = line.split_whitespace().collect();
127 if parts.len() < 2 {
128 return Err(format!(
129 "Invalid column line in table '{}': {}",
130 table.name, line
131 ));
132 }
133 let col_name = parts[0];
134 let col_type = parts[1];
135 let not_null = parts.len() > 2
136 && parts.iter().any(|&p| p.eq_ignore_ascii_case("not"))
137 && parts.iter().any(|&p| p.eq_ignore_ascii_case("null"));
138
139 table.columns.push(ColumnDef {
140 name: col_name.to_string(),
141 typ: col_type.to_string(),
142 nullable: !not_null,
143 primary_key: false,
144 });
145 }
146 }
147
148 if let Some(t) = current_table {
150 schema.tables.push(t);
151 }
152
153 Ok(schema)
154 }
155
156 pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
158 let content = crate::schema_source::read_qail_schema_source(path)?;
159 Self::from_qail_schema(&content)
160 }
161}
162
163impl Default for Schema {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl TableDef {
170 pub fn new(name: &str) -> Self {
172 Self {
173 name: name.to_string(),
174 columns: Vec::new(),
175 }
176 }
177
178 pub fn add_column(&mut self, col: ColumnDef) {
180 self.columns.push(col);
181 }
182
183 pub fn column(mut self, name: &str, typ: &str) -> Self {
185 self.columns.push(ColumnDef {
186 name: name.to_string(),
187 typ: typ.to_string(),
188 nullable: true,
189 primary_key: false,
190 });
191 self
192 }
193
194 pub fn pk(mut self, name: &str, typ: &str) -> Self {
196 self.columns.push(ColumnDef {
197 name: name.to_string(),
198 typ: typ.to_string(),
199 nullable: false,
200 primary_key: true,
201 });
202 self
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use std::sync::Mutex;
210
211 static RELATION_TEST_LOCK: Mutex<()> = Mutex::new(());
212
213 #[test]
214 fn test_schema_from_qail_schema() {
215 let qail = r#"
216table users (
217 id uuid not null,
218 email varchar not null
219)
220"#;
221
222 let schema = Schema::from_qail_schema(qail).unwrap();
223 assert_eq!(schema.tables.len(), 1);
224 assert_eq!(schema.tables[0].name, "users");
225 assert_eq!(schema.tables[0].columns.len(), 2);
226 }
227
228 #[test]
229 fn test_schema_to_validator() {
230 let schema = Schema {
231 tables: vec![
232 TableDef::new("users")
233 .pk("id", "uuid")
234 .column("email", "varchar"),
235 ],
236 };
237
238 let validator = schema.to_validator();
239 assert!(validator.validate_table("users").is_ok());
240 assert!(validator.validate_column("users", "id").is_ok());
241 assert!(validator.validate_column("users", "email").is_ok());
242 }
243
244 #[test]
245 fn test_table_builder() {
246 let table = TableDef::new("orders")
247 .pk("id", "uuid")
248 .column("total", "decimal")
249 .column("status", "varchar");
250
251 assert_eq!(table.columns.len(), 3);
252 assert!(table.columns[0].primary_key);
253 }
254
255 #[test]
260 fn test_build_schema_parses_ref_syntax() {
261 let schema_content = r#"
262table users {
263 id UUID primary_key
264 email TEXT
265}
266
267table posts {
268 id UUID primary_key
269 user_id UUID ref:users.id
270 title TEXT
271}
272"#;
273
274 let schema = crate::build::Schema::parse(schema_content).unwrap();
275
276 assert!(schema.has_table("users"));
278 assert!(schema.has_table("posts"));
279
280 let posts = schema.table("posts").unwrap();
282 assert_eq!(posts.foreign_keys.len(), 1);
283
284 let fk = &posts.foreign_keys[0];
285 assert_eq!(fk.column, "user_id");
286 assert_eq!(fk.ref_table, "users");
287 assert_eq!(fk.ref_column, "id");
288 }
289
290 #[test]
291 fn test_relation_registry_forward_lookup() {
292 let mut registry = RelationRegistry::new();
293 registry.register("posts", "user_id", "users", "id");
294
295 let result = registry.get("posts", "users");
297 assert!(result.is_some());
298 let (from_col, to_col) = result.unwrap();
299 assert_eq!(from_col, "user_id");
300 assert_eq!(to_col, "id");
301 }
302
303 #[test]
304 fn test_relation_registry_from_build_schema() {
305 let schema_content = r#"
306table users {
307 id UUID
308}
309
310table posts {
311 user_id UUID ref:users.id
312}
313
314table comments {
315 post_id UUID ref:posts.id
316 user_id UUID ref:users.id
317}
318"#;
319
320 let schema = crate::build::Schema::parse(schema_content).unwrap();
321 let registry = RelationRegistry::from_build_schema(&schema);
322
323 assert!(registry.get("posts", "users").is_some());
325
326 assert!(registry.get("comments", "posts").is_some());
328
329 assert!(registry.get("comments", "users").is_some());
331
332 let referencing = registry.referencing("users");
334 assert!(referencing.contains(&"posts"));
335 assert!(referencing.contains(&"comments"));
336 }
337
338 #[test]
339 fn test_join_on_produces_correct_ast() {
340 use crate::Qail;
341 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
342
343 {
345 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
346 reg.register("posts", "user_id", "users", "id");
347 }
348
349 let query = Qail::get("users")
352 .join_on("posts")
353 .expect("relation should join");
354
355 assert_eq!(query.joins.len(), 1);
356 let join = &query.joins[0];
357 assert_eq!(join.table, "posts");
358
359 let on = join.on.as_ref().expect("Should have ON conditions");
361 assert_eq!(on.len(), 1);
362
363 {
365 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
366 *reg = RelationRegistry::new();
367 }
368 }
369
370 #[test]
371 fn test_join_on_optional_returns_self_when_no_relation() {
372 use crate::Qail;
373 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
374
375 {
377 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
378 *reg = RelationRegistry::new();
379 }
380
381 let query = Qail::get("users").join_on_optional("nonexistent");
383 assert!(query.joins.is_empty());
384 }
385
386 #[test]
387 fn test_join_on_returns_error_on_ambiguous_relation() {
388 use crate::Qail;
389 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
390
391 {
392 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
393 *reg = RelationRegistry::new();
394 reg.register("invoices", "buyer_id", "users", "id");
395 reg.register("invoices", "seller_id", "users", "id");
396 }
397
398 let err = Qail::get("invoices")
399 .join_on("users")
400 .expect_err("ambiguous relation should error");
401 assert!(err.to_string().contains("Ambiguous relation"));
402
403 {
404 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
405 *reg = RelationRegistry::new();
406 }
407 }
408
409 #[test]
410 fn test_join_on_optional_returns_self_on_ambiguous_relation() {
411 use crate::Qail;
412 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
413
414 {
415 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
416 *reg = RelationRegistry::new();
417 reg.register("invoices", "buyer_id", "users", "id");
418 reg.register("invoices", "seller_id", "users", "id");
419 }
420
421 let query = Qail::get("invoices").join_on_optional("users");
422 assert!(query.joins.is_empty());
423
424 {
425 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
426 *reg = RelationRegistry::new();
427 }
428 }
429
430 #[test]
431 fn test_join_on_returns_error_when_no_relation() {
432 use crate::Qail;
433 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
434
435 {
436 let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
437 *reg = RelationRegistry::new();
438 }
439
440 let err = Qail::get("users")
441 .join_on("nonexistent")
442 .expect_err("missing relation should error");
443 assert!(err.to_string().contains("No relation found"));
444 }
445
446 #[test]
447 fn test_from_qail_schema_supports_brace_table_blocks() {
448 let qail = r#"
449table users {
450 id uuid not null
451 email varchar
452}
453"#;
454 let schema = Schema::from_qail_schema(qail).expect("brace-style schema should parse");
455 assert_eq!(schema.tables.len(), 1);
456 assert_eq!(schema.tables[0].name, "users");
457 assert_eq!(schema.tables[0].columns.len(), 2);
458 }
459
460 #[test]
461 fn test_from_qail_schema_errors_on_malformed_column_line() {
462 let qail = r#"
463table users (
464 id uuid not null,
465 email,
466)
467"#;
468 let err = Schema::from_qail_schema(qail).expect_err("malformed column should error");
469 assert!(err.contains("Invalid column line"));
470 assert!(err.contains("users"));
471 }
472
473 #[test]
474 fn test_from_qail_schema_ignores_hash_and_inline_comments() {
475 let qail = r#"
476# top-level comment
477table users { -- inline table comment
478 id uuid not null, # id comment
479 # line comment inside table
480 email varchar -- email comment
481}
482"#;
483 let schema = Schema::from_qail_schema(qail).expect("schema with comments should parse");
484 assert_eq!(schema.tables.len(), 1);
485 assert_eq!(schema.tables[0].name, "users");
486 assert_eq!(schema.tables[0].columns.len(), 2);
487 assert_eq!(schema.tables[0].columns[0].name, "id");
488 assert_eq!(schema.tables[0].columns[1].name, "email");
489 }
490
491 #[test]
492 fn test_replace_schema_relations_replaces_registry_state() {
493 use std::fs;
494 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
495
496 {
498 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
499 *reg = RelationRegistry::new();
500 }
501
502 let base = std::env::temp_dir().join(format!(
503 "qail_schema_relations_reload_{}",
504 std::time::SystemTime::now()
505 .duration_since(std::time::UNIX_EPOCH)
506 .expect("clock")
507 .as_nanos()
508 ));
509 fs::create_dir_all(&base).expect("mkdir temp");
510
511 let schema_with_fk = base.join("schema_with_fk.qail");
512 fs::write(
513 &schema_with_fk,
514 r#"
515table users {
516 id UUID primary_key
517}
518table posts {
519 id UUID primary_key
520 user_id UUID ref:users.id
521}
522"#,
523 )
524 .expect("write schema 1");
525
526 let schema_without_fk = base.join("schema_without_fk.qail");
527 fs::write(
528 &schema_without_fk,
529 r#"
530table users {
531 id UUID primary_key
532}
533table posts {
534 id UUID primary_key
535}
536"#,
537 )
538 .expect("write schema 2");
539
540 let count1 = replace_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
541 assert_eq!(count1, 1);
542 assert!(lookup_relation("posts", "users").is_some());
543
544 let count2 =
545 replace_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
546 assert_eq!(count2, 0);
547 assert!(lookup_relation("posts", "users").is_none());
548
549 {
550 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
551 *reg = RelationRegistry::new();
552 }
553 let _ = fs::remove_dir_all(base);
554 }
555
556 #[test]
557 fn test_load_schema_relations_merges_registry_state() {
558 use std::fs;
559 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
560
561 {
562 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
563 *reg = RelationRegistry::new();
564 }
565
566 let base = std::env::temp_dir().join(format!(
567 "qail_schema_relations_merge_{}",
568 std::time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH)
570 .expect("clock")
571 .as_nanos()
572 ));
573 fs::create_dir_all(&base).expect("mkdir temp");
574
575 let schema_with_fk = base.join("schema_with_fk.qail");
576 fs::write(
577 &schema_with_fk,
578 r#"
579table users {
580 id UUID primary_key
581}
582table posts {
583 id UUID primary_key
584 user_id UUID ref:users.id
585}
586"#,
587 )
588 .expect("write schema 1");
589
590 let schema_without_fk = base.join("schema_without_fk.qail");
591 fs::write(
592 &schema_without_fk,
593 r#"
594table invoices {
595 id UUID primary_key
596 user_id UUID ref:users.id
597}
598"#,
599 )
600 .expect("write schema 2");
601
602 let count1 = load_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
603 assert_eq!(count1, 1);
604 assert!(lookup_relation("posts", "users").is_some());
605
606 let count2 = load_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
607 assert_eq!(count2, 1);
608 assert!(lookup_relation("posts", "users").is_some());
609 assert!(lookup_relation("invoices", "users").is_some());
610
611 {
612 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
613 *reg = RelationRegistry::new();
614 }
615 let _ = fs::remove_dir_all(base);
616 }
617
618 #[test]
619 fn test_merge_schema_relations_merges_registry_state() {
620 use std::fs;
621 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
622
623 {
624 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
625 *reg = RelationRegistry::new();
626 }
627
628 let base = std::env::temp_dir().join(format!(
629 "qail_schema_relations_merge_{}",
630 std::time::SystemTime::now()
631 .duration_since(std::time::UNIX_EPOCH)
632 .expect("clock")
633 .as_nanos()
634 ));
635 fs::create_dir_all(&base).expect("mkdir temp");
636
637 let schema_with_fk = base.join("schema_with_fk.qail");
638 fs::write(
639 &schema_with_fk,
640 r#"
641table users {
642 id UUID primary_key
643}
644table posts {
645 id UUID primary_key
646 user_id UUID ref:users.id
647}
648"#,
649 )
650 .expect("write schema 1");
651
652 let schema_without_fk = base.join("schema_without_fk.qail");
653 fs::write(
654 &schema_without_fk,
655 r#"
656table invoices {
657 id UUID primary_key
658}
659"#,
660 )
661 .expect("write schema 2");
662
663 let count1 = merge_schema_relations(schema_with_fk.to_str().expect("path utf8")).unwrap();
664 assert_eq!(count1, 1);
665 assert!(lookup_relation("posts", "users").is_some());
666
667 let count2 =
668 merge_schema_relations(schema_without_fk.to_str().expect("path utf8")).unwrap();
669 assert_eq!(count2, 0);
670 assert!(lookup_relation("posts", "users").is_some());
671
672 {
673 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
674 *reg = RelationRegistry::new();
675 }
676 let _ = fs::remove_dir_all(base);
677 }
678
679 #[test]
680 fn test_lookup_relation_state_errors_on_ambiguous_multi_fk_pair() {
681 let _guard = RELATION_TEST_LOCK.lock().expect("relation test lock");
682 let schema_content = r#"
683table users {
684 id UUID primary_key
685}
686
687table invoices {
688 id UUID primary_key
689 buyer_id UUID ref:users.id
690 seller_id UUID ref:users.id
691}
692"#;
693
694 let schema = crate::build::Schema::parse(schema_content).expect("schema parse");
695 let registry = RelationRegistry::from_build_schema(&schema);
696 {
697 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
698 *reg = registry;
699 }
700
701 let err = lookup_relation_state("invoices", "users").expect_err("ambiguous relation");
702 assert!(err.to_string().contains("Ambiguous relation"));
703
704 {
705 let mut reg = super::RUNTIME_RELATIONS.write().expect("registry lock");
706 *reg = RelationRegistry::new();
707 }
708 }
709}
710
711use std::collections::HashMap;
712use std::sync::LazyLock;
713use std::sync::RwLock;
714
715#[derive(Debug, Default)]
717pub struct RelationRegistry {
718 forward: HashMap<(String, String), Vec<(String, String)>>,
720 reverse: HashMap<String, Vec<String>>,
722}
723
724impl RelationRegistry {
725 pub fn new() -> Self {
727 Self::default()
728 }
729
730 pub fn register(&mut self, from_table: &str, from_col: &str, to_table: &str, to_col: &str) {
739 let entry = self
740 .forward
741 .entry((from_table.to_string(), to_table.to_string()))
742 .or_default();
743 let pair = (from_col.to_string(), to_col.to_string());
744 if !entry.iter().any(|existing| existing == &pair) {
745 entry.push(pair);
746 }
747
748 let entry = self.reverse.entry(to_table.to_string()).or_default();
749 if !entry.iter().any(|existing| existing == from_table) {
750 entry.push(from_table.to_string());
751 }
752 }
753
754 pub fn get(&self, from_table: &str, to_table: &str) -> Option<(&str, &str)> {
763 let options = self.get_all(from_table, to_table)?;
764 if options.len() != 1 {
765 return None;
766 }
767 let (a, b) = &options[0];
768 Some((a.as_str(), b.as_str()))
769 }
770
771 pub fn get_all(&self, from_table: &str, to_table: &str) -> Option<&[(String, String)]> {
773 self.forward
774 .get(&(from_table.to_string(), to_table.to_string()))
775 .map(|pairs| pairs.as_slice())
776 }
777
778 pub fn referencing(&self, table: &str) -> Vec<&str> {
780 self.reverse
781 .get(table)
782 .map(|v| v.iter().map(|s| s.as_str()).collect())
783 .unwrap_or_default()
784 }
785
786 pub fn from_build_schema(schema: &crate::build::Schema) -> Self {
788 let mut registry = Self::new();
789
790 for table in schema.tables.values() {
791 for fk in &table.foreign_keys {
792 registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
793 }
794 }
795
796 registry
797 }
798}
799
800pub static RUNTIME_RELATIONS: LazyLock<RwLock<RelationRegistry>> =
802 LazyLock::new(|| RwLock::new(RelationRegistry::new()));
803
804pub fn load_schema_relations(path: &str) -> Result<usize, String> {
809 merge_schema_relations(path)
810}
811
812pub fn merge_schema_relations(path: &str) -> Result<usize, String> {
817 let schema = crate::build::Schema::parse_file(path)?;
818 let count: usize = schema
819 .tables
820 .values()
821 .map(|table| table.foreign_keys.len())
822 .sum();
823 let mut registry = RUNTIME_RELATIONS
824 .write()
825 .map_err(|e| format!("Lock error: {}", e))?;
826 for table in schema.tables.values() {
827 for fk in &table.foreign_keys {
828 registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
829 }
830 }
831
832 Ok(count)
833}
834
835pub fn replace_schema_relations(path: &str) -> Result<usize, String> {
840 let schema = crate::build::Schema::parse_file(path)?;
841 let replacement = RelationRegistry::from_build_schema(&schema);
842 let count: usize = schema
843 .tables
844 .values()
845 .map(|table| table.foreign_keys.len())
846 .sum();
847 let mut registry = RUNTIME_RELATIONS
848 .write()
849 .map_err(|e| format!("Lock error: {}", e))?;
850 *registry = replacement;
851
852 Ok(count)
853}
854
855pub fn lookup_relation(from_table: &str, to_table: &str) -> Option<(String, String)> {
858 lookup_relation_state(from_table, to_table).ok().flatten()
859}
860
861pub fn lookup_relation_state(
863 from_table: &str,
864 to_table: &str,
865) -> crate::error::QailBuildResult<Option<(String, String)>> {
866 let registry = RUNTIME_RELATIONS
867 .read()
868 .map_err(|e| crate::error::QailBuildError::RelationRegistryLock(e.to_string()))?;
869 let Some(options) = registry.get_all(from_table, to_table) else {
870 return Ok(None);
871 };
872
873 if options.len() > 1 {
874 return Err(crate::error::QailBuildError::AmbiguousRelation {
875 from_table: from_table.to_string(),
876 to_table: to_table.to_string(),
877 foreign_key_count: options.len(),
878 });
879 }
880
881 let (fc, tc) = options[0].clone();
882 Ok(Some((fc, tc)))
883}