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 line = line.split_once("--").map_or(line, |(left, _)| left);
24    line.split_once('#').map_or(line, |(left, _)| left).trim()
25}
26
27/// A database schema comprising one or more table definitions.
28#[derive(Debug, Clone)]
29pub struct Schema {
30    /// Table definitions.
31    pub tables: Vec<TableDef>,
32}
33
34/// Definition of a single table.
35#[derive(Debug, Clone)]
36pub struct TableDef {
37    /// Table name.
38    pub name: String,
39    /// Column definitions.
40    pub columns: Vec<ColumnDef>,
41}
42
43/// Definition of a single column.
44#[derive(Debug, Clone)]
45pub struct ColumnDef {
46    /// Column name.
47    pub name: String,
48    /// SQL data type.
49    pub typ: String,
50    /// Whether the column accepts NULL.
51    pub nullable: bool,
52    /// Whether the column is a primary key.
53    pub primary_key: bool,
54}
55
56impl Schema {
57    /// Create an empty schema.
58    pub fn new() -> Self {
59        Self { tables: Vec::new() }
60    }
61
62    /// Add a table to the schema.
63    pub fn add_table(&mut self, table: TableDef) {
64        self.tables.push(table);
65    }
66
67    /// Convert schema to a Validator for query validation.
68    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    /// Load schema from QAIL schema format (schema.qail).
78    /// Parses text like:
79    /// ```text
80    ///     id string not null,
81    ///     email string not null,
82    ///     created_at date
83    /// )
84    /// ```
85    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            // Skip empty lines and comments
93            if line.is_empty() {
94                continue;
95            }
96
97            // Match "table tablename ("
98            if let Some(rest) = line.strip_prefix("table ") {
99                // Save previous table if any
100                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            // Match closing paren
116            else if matches!(line.trim_end_matches(';'), ")" | "}") {
117                if let Some(t) = current_table.take() {
118                    schema.tables.push(t);
119                }
120            }
121            // Match column definition: "name type [not null],"
122            else if let Some(ref mut table) = current_table {
123                // Remove trailing comma
124                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        // Don't forget the last table
149        if let Some(t) = current_table {
150            schema.tables.push(t);
151        }
152
153        Ok(schema)
154    }
155
156    /// Load schema from QAIL schema source path (file or modular directory).
157    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    /// Create a new table definition.
171    pub fn new(name: &str) -> Self {
172        Self {
173            name: name.to_string(),
174            columns: Vec::new(),
175        }
176    }
177
178    /// Add a column to the table.
179    pub fn add_column(&mut self, col: ColumnDef) {
180        self.columns.push(col);
181    }
182
183    /// Builder: add a simple column.
184    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    /// Builder: add a primary key column.
195    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    // =========================================================================
256    // First-Class Relations Tests
257    // =========================================================================
258
259    #[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        // Check tables exist
277        assert!(schema.has_table("users"));
278        assert!(schema.has_table("posts"));
279
280        // Check foreign key was parsed
281        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        // Forward lookup: posts -> users
296        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        // Check posts -> users
324        assert!(registry.get("posts", "users").is_some());
325
326        // Check comments -> posts
327        assert!(registry.get("comments", "posts").is_some());
328
329        // Check comments -> users
330        assert!(registry.get("comments", "users").is_some());
331
332        // Check reverse lookups
333        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        // Setup: Register a relation manually
344        {
345            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
346            reg.register("posts", "user_id", "users", "id");
347        }
348
349        // Test forward join: from users, join posts
350        // This should find reverse: posts.user_id -> users.id
351        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        // Verify join conditions
360        let on = join.on.as_ref().expect("Should have ON conditions");
361        assert_eq!(on.len(), 1);
362
363        // Clean up
364        {
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        // Clear registry
376        {
377            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
378            *reg = RelationRegistry::new();
379        }
380
381        // join_on_optional should not panic, just return self unchanged
382        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        // Ensure clean global state for this test.
497        {
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/// Registry of table foreign-key relationships for auto-join inference.
716#[derive(Debug, Default)]
717pub struct RelationRegistry {
718    /// Forward lookups: (from_table, to_table) -> [(from_col, to_col), ...]
719    forward: HashMap<(String, String), Vec<(String, String)>>,
720    /// Reverse lookups: to_table -> list of tables that reference it
721    reverse: HashMap<String, Vec<String>>,
722}
723
724impl RelationRegistry {
725    /// Create a new empty registry.
726    pub fn new() -> Self {
727        Self::default()
728    }
729
730    /// Register a foreign-key relation from schema.
731    ///
732    /// # Arguments
733    ///
734    /// * `from_table` — Source (referencing) table.
735    /// * `from_col` — Foreign-key column in the source table.
736    /// * `to_table` — Target (referenced) table.
737    /// * `to_col` — Primary-key column in the target table.
738    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    /// Lookup join columns for a relation.
755    ///
756    /// Returns `(from_col, to_col)` if the relation exists.
757    ///
758    /// # Arguments
759    ///
760    /// * `from_table` — Source table name.
761    /// * `to_table` — Target table name.
762    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    /// Lookup all join-column candidates for a relation.
772    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    /// Get all tables that reference this table (for reverse joins).
779    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    /// Load relations from a parsed build::Schema.
787    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
800/// Global mutable registry for runtime schema loading.
801pub static RUNTIME_RELATIONS: LazyLock<RwLock<RelationRegistry>> =
802    LazyLock::new(|| RwLock::new(RelationRegistry::new()));
803
804/// Load relations from a schema.qail file into the runtime registry.
805///
806/// This function merges relations into the existing runtime relation state.
807/// Returns the number of relations parsed from `path`.
808pub fn load_schema_relations(path: &str) -> Result<usize, String> {
809    merge_schema_relations(path)
810}
811
812/// Merge relations from a schema.qail file into the runtime registry.
813///
814/// Use this when multiple schema fragments are loaded incrementally and previously
815/// registered relations should be retained.
816pub 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
835/// Replace all runtime relations with relations loaded from a schema.qail file.
836///
837/// Use this for hot-reload workflows where runtime registry state should exactly
838/// match a schema snapshot.
839pub 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
855/// Lookup join info for implicit join.
856/// Returns (from_col, to_col) if relation exists.
857pub fn lookup_relation(from_table: &str, to_table: &str) -> Option<(String, String)> {
858    lookup_relation_state(from_table, to_table).ok().flatten()
859}
860
861/// Lookup join info and return an explicit error when relation metadata is ambiguous.
862pub 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}