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