Skip to main content

qail_core/
schema.rs

1//! Schema definitions for QAIL validation.
2//!
3//! Provides types for representing database schemas and loading them from QAIL schema sources.
4//!
5//! # Example
6//! ```
7//! use qail_core::schema::Schema;
8//!
9//! let qail = r#"
10//! table users (
11//!     id uuid not null,
12//!     email varchar not null
13//! )
14//! "#;
15//!
16//! let schema = Schema::from_qail_schema(qail).unwrap();
17//! let validator = schema.to_validator();
18//! ```
19
20use 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/// A database schema comprising one or more table definitions.
64#[derive(Debug, Clone)]
65pub struct Schema {
66    /// Table definitions.
67    pub tables: Vec<TableDef>,
68}
69
70/// Definition of a single table.
71#[derive(Debug, Clone)]
72pub struct TableDef {
73    /// Table name.
74    pub name: String,
75    /// Column definitions.
76    pub columns: Vec<ColumnDef>,
77}
78
79/// Definition of a single column.
80#[derive(Debug, Clone)]
81pub struct ColumnDef {
82    /// Column name.
83    pub name: String,
84    /// SQL data type.
85    pub typ: String,
86    /// Whether the column accepts NULL.
87    pub nullable: bool,
88    /// Whether the column is a primary key.
89    pub primary_key: bool,
90}
91
92impl Schema {
93    /// Create an empty schema.
94    pub fn new() -> Self {
95        Self { tables: Vec::new() }
96    }
97
98    /// Add a table to the schema.
99    pub fn add_table(&mut self, table: TableDef) {
100        self.tables.push(table);
101    }
102
103    /// Convert schema to a Validator for query validation.
104    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    /// Load schema from QAIL schema format (schema.qail).
114    /// Parses text like:
115    /// ```text
116    ///     id string not null,
117    ///     email string not null,
118    ///     created_at date
119    /// )
120    /// ```
121    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            // Skip empty lines and comments
129            if line.is_empty() {
130                continue;
131            }
132
133            // Match "table tablename ("
134            if let Some(rest) = line.strip_prefix("table ") {
135                // Save previous table if any
136                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            // Match closing paren
152            else if matches!(line.trim_end_matches(';'), ")" | "}") {
153                if let Some(t) = current_table.take() {
154                    schema.tables.push(t);
155                }
156            }
157            // Match column definition: "name type [not null],"
158            else if let Some(ref mut table) = current_table {
159                // Remove trailing comma
160                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        // Don't forget the last table
185        if let Some(t) = current_table {
186            schema.tables.push(t);
187        }
188
189        Ok(schema)
190    }
191
192    /// Load schema from QAIL schema source path (file or modular directory).
193    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    /// Create a new table definition.
207    pub fn new(name: &str) -> Self {
208        Self {
209            name: name.to_string(),
210            columns: Vec::new(),
211        }
212    }
213
214    /// Add a column to the table.
215    pub fn add_column(&mut self, col: ColumnDef) {
216        self.columns.push(col);
217    }
218
219    /// Builder: add a simple column.
220    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    /// Builder: add a primary key column.
231    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    // =========================================================================
292    // First-Class Relations Tests
293    // =========================================================================
294
295    #[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        // Check tables exist
313        assert!(schema.has_table("users"));
314        assert!(schema.has_table("posts"));
315
316        // Check foreign key was parsed
317        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        // Forward lookup: posts -> users
332        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        // Check posts -> users
360        assert!(registry.get("posts", "users").is_some());
361
362        // Check comments -> posts
363        assert!(registry.get("comments", "posts").is_some());
364
365        // Check comments -> users
366        assert!(registry.get("comments", "users").is_some());
367
368        // Check reverse lookups
369        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        // Setup: Register a relation manually
380        {
381            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
382            reg.register("posts", "user_id", "users", "id");
383        }
384
385        // Test forward join: from users, join posts
386        // This should find reverse: posts.user_id -> users.id
387        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        // Verify join conditions
396        let on = join.on.as_ref().expect("Should have ON conditions");
397        assert_eq!(on.len(), 1);
398
399        // Clean up
400        {
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        // Clear registry
412        {
413            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
414            *reg = RelationRegistry::new();
415        }
416
417        // join_on_optional should not panic, just return self unchanged
418        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        // Ensure clean global state for this test.
545        {
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/// Registry of table foreign-key relationships for auto-join inference.
764#[derive(Debug, Default)]
765pub struct RelationRegistry {
766    /// Forward lookups: (from_table, to_table) -> [(from_col, to_col), ...]
767    forward: HashMap<(String, String), Vec<(String, String)>>,
768    /// Reverse lookups: to_table -> list of tables that reference it
769    reverse: HashMap<String, Vec<String>>,
770}
771
772impl RelationRegistry {
773    /// Create a new empty registry.
774    pub fn new() -> Self {
775        Self::default()
776    }
777
778    /// Register a foreign-key relation from schema.
779    ///
780    /// # Arguments
781    ///
782    /// * `from_table` — Source (referencing) table.
783    /// * `from_col` — Foreign-key column in the source table.
784    /// * `to_table` — Target (referenced) table.
785    /// * `to_col` — Primary-key column in the target table.
786    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    /// Lookup join columns for a relation.
803    ///
804    /// Returns `(from_col, to_col)` if the relation exists.
805    ///
806    /// # Arguments
807    ///
808    /// * `from_table` — Source table name.
809    /// * `to_table` — Target table name.
810    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    /// Lookup all join-column candidates for a relation.
820    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    /// Get all tables that reference this table (for reverse joins).
827    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    /// Load relations from a parsed build::Schema.
835    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
848/// Global mutable registry for runtime schema loading.
849pub static RUNTIME_RELATIONS: LazyLock<RwLock<RelationRegistry>> =
850    LazyLock::new(|| RwLock::new(RelationRegistry::new()));
851
852/// Load relations from a schema.qail file into the runtime registry.
853///
854/// This function merges relations into the existing runtime relation state.
855/// Returns the number of relations parsed from `path`.
856pub fn load_schema_relations(path: &str) -> Result<usize, String> {
857    merge_schema_relations(path)
858}
859
860/// Merge relations from a schema.qail file into the runtime registry.
861///
862/// Use this when multiple schema fragments are loaded incrementally and previously
863/// registered relations should be retained.
864pub 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
883/// Replace all runtime relations with relations loaded from a schema.qail file.
884///
885/// Use this for hot-reload workflows where runtime registry state should exactly
886/// match a schema snapshot.
887pub 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
903/// Lookup join info for implicit join.
904/// Returns (from_col, to_col) if relation exists.
905pub fn lookup_relation(from_table: &str, to_table: &str) -> Option<(String, String)> {
906    lookup_relation_state(from_table, to_table).ok().flatten()
907}
908
909/// Lookup join info and return an explicit error when relation metadata is ambiguous.
910pub 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}