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
22/// A database schema comprising one or more table definitions.
23#[derive(Debug, Clone)]
24pub struct Schema {
25    /// Table definitions.
26    pub tables: Vec<TableDef>,
27}
28
29/// Definition of a single table.
30#[derive(Debug, Clone)]
31pub struct TableDef {
32    /// Table name.
33    pub name: String,
34    /// Column definitions.
35    pub columns: Vec<ColumnDef>,
36}
37
38/// Definition of a single column.
39#[derive(Debug, Clone)]
40pub struct ColumnDef {
41    /// Column name.
42    pub name: String,
43    /// SQL data type.
44    pub typ: String,
45    /// Whether the column accepts NULL.
46    pub nullable: bool,
47    /// Whether the column is a primary key.
48    pub primary_key: bool,
49}
50
51impl Schema {
52    /// Create an empty schema.
53    pub fn new() -> Self {
54        Self { tables: Vec::new() }
55    }
56
57    /// Add a table to the schema.
58    pub fn add_table(&mut self, table: TableDef) {
59        self.tables.push(table);
60    }
61
62    /// Convert schema to a Validator for query validation.
63    pub fn to_validator(&self) -> Validator {
64        let mut v = Validator::new();
65        for table in &self.tables {
66            let cols: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
67            v.add_table(&table.name, &cols);
68        }
69        v
70    }
71
72    /// Load schema from QAIL schema format (schema.qail).
73    /// Parses text like:
74    /// ```text
75    ///     id string not null,
76    ///     email string not null,
77    ///     created_at date
78    /// )
79    /// ```
80    pub fn from_qail_schema(input: &str) -> Result<Self, String> {
81        let mut schema = Schema::new();
82        let mut current_table: Option<TableDef> = None;
83
84        for line in input.lines() {
85            let line = line.trim();
86
87            // Skip empty lines and comments
88            if line.is_empty() || line.starts_with("--") {
89                continue;
90            }
91
92            // Match "table tablename ("
93            if let Some(rest) = line.strip_prefix("table ") {
94                // Save previous table if any
95                if let Some(t) = current_table.take() {
96                    schema.tables.push(t);
97                }
98
99                // Skip "table "
100                let name = rest
101                    .split('(')
102                    .next()
103                    .map(|s| s.trim())
104                    .ok_or_else(|| format!("Invalid table line: {}", line))?;
105
106                current_table = Some(TableDef::new(name));
107            }
108            // Match closing paren
109            else if line == ")" {
110                if let Some(t) = current_table.take() {
111                    schema.tables.push(t);
112                }
113            }
114            // Match column definition: "name type [not null],"
115            else if let Some(ref mut table) = current_table {
116                // Remove trailing comma
117                let line = line.trim_end_matches(',');
118
119                let parts: Vec<&str> = line.split_whitespace().collect();
120                if parts.len() >= 2 {
121                    let col_name = parts[0];
122                    let col_type = parts[1];
123                    let not_null = parts.len() > 2
124                        && parts.iter().any(|&p| p.eq_ignore_ascii_case("not"))
125                        && parts.iter().any(|&p| p.eq_ignore_ascii_case("null"));
126
127                    table.columns.push(ColumnDef {
128                        name: col_name.to_string(),
129                        typ: col_type.to_string(),
130                        nullable: !not_null,
131                        primary_key: false,
132                    });
133                }
134            }
135        }
136
137        // Don't forget the last table
138        if let Some(t) = current_table {
139            schema.tables.push(t);
140        }
141
142        Ok(schema)
143    }
144
145    /// Load schema from QAIL schema source path (file or modular directory).
146    pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
147        let content = crate::schema_source::read_qail_schema_source(path)?;
148        Self::from_qail_schema(&content)
149    }
150}
151
152impl Default for Schema {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158impl TableDef {
159    /// Create a new table definition.
160    pub fn new(name: &str) -> Self {
161        Self {
162            name: name.to_string(),
163            columns: Vec::new(),
164        }
165    }
166
167    /// Add a column to the table.
168    pub fn add_column(&mut self, col: ColumnDef) {
169        self.columns.push(col);
170    }
171
172    /// Builder: add a simple column.
173    pub fn column(mut self, name: &str, typ: &str) -> Self {
174        self.columns.push(ColumnDef {
175            name: name.to_string(),
176            typ: typ.to_string(),
177            nullable: true,
178            primary_key: false,
179        });
180        self
181    }
182
183    /// Builder: add a primary key column.
184    pub fn pk(mut self, name: &str, typ: &str) -> Self {
185        self.columns.push(ColumnDef {
186            name: name.to_string(),
187            typ: typ.to_string(),
188            nullable: false,
189            primary_key: true,
190        });
191        self
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_schema_from_qail_schema() {
201        let qail = r#"
202table users (
203    id uuid not null,
204    email varchar not null
205)
206"#;
207
208        let schema = Schema::from_qail_schema(qail).unwrap();
209        assert_eq!(schema.tables.len(), 1);
210        assert_eq!(schema.tables[0].name, "users");
211        assert_eq!(schema.tables[0].columns.len(), 2);
212    }
213
214    #[test]
215    fn test_schema_to_validator() {
216        let schema = Schema {
217            tables: vec![
218                TableDef::new("users")
219                    .pk("id", "uuid")
220                    .column("email", "varchar"),
221            ],
222        };
223
224        let validator = schema.to_validator();
225        assert!(validator.validate_table("users").is_ok());
226        assert!(validator.validate_column("users", "id").is_ok());
227        assert!(validator.validate_column("users", "email").is_ok());
228    }
229
230    #[test]
231    fn test_table_builder() {
232        let table = TableDef::new("orders")
233            .pk("id", "uuid")
234            .column("total", "decimal")
235            .column("status", "varchar");
236
237        assert_eq!(table.columns.len(), 3);
238        assert!(table.columns[0].primary_key);
239    }
240
241    // =========================================================================
242    // First-Class Relations Tests
243    // =========================================================================
244
245    #[test]
246    fn test_build_schema_parses_ref_syntax() {
247        let schema_content = r#"
248table users {
249    id UUID primary_key
250    email TEXT
251}
252
253table posts {
254    id UUID primary_key
255    user_id UUID ref:users.id
256    title TEXT
257}
258"#;
259
260        let schema = crate::build::Schema::parse(schema_content).unwrap();
261
262        // Check tables exist
263        assert!(schema.has_table("users"));
264        assert!(schema.has_table("posts"));
265
266        // Check foreign key was parsed
267        let posts = schema.table("posts").unwrap();
268        assert_eq!(posts.foreign_keys.len(), 1);
269
270        let fk = &posts.foreign_keys[0];
271        assert_eq!(fk.column, "user_id");
272        assert_eq!(fk.ref_table, "users");
273        assert_eq!(fk.ref_column, "id");
274    }
275
276    #[test]
277    fn test_relation_registry_forward_lookup() {
278        let mut registry = RelationRegistry::new();
279        registry.register("posts", "user_id", "users", "id");
280
281        // Forward lookup: posts -> users
282        let result = registry.get("posts", "users");
283        assert!(result.is_some());
284        let (from_col, to_col) = result.unwrap();
285        assert_eq!(from_col, "user_id");
286        assert_eq!(to_col, "id");
287    }
288
289    #[test]
290    fn test_relation_registry_from_build_schema() {
291        let schema_content = r#"
292table users {
293    id UUID
294}
295
296table posts {
297    user_id UUID ref:users.id
298}
299
300table comments {
301    post_id UUID ref:posts.id
302    user_id UUID ref:users.id
303}
304"#;
305
306        let schema = crate::build::Schema::parse(schema_content).unwrap();
307        let registry = RelationRegistry::from_build_schema(&schema);
308
309        // Check posts -> users
310        assert!(registry.get("posts", "users").is_some());
311
312        // Check comments -> posts
313        assert!(registry.get("comments", "posts").is_some());
314
315        // Check comments -> users
316        assert!(registry.get("comments", "users").is_some());
317
318        // Check reverse lookups
319        let referencing = registry.referencing("users");
320        assert!(referencing.contains(&"posts"));
321        assert!(referencing.contains(&"comments"));
322    }
323
324    #[test]
325    fn test_join_on_produces_correct_ast() {
326        use crate::Qail;
327
328        // Setup: Register a relation manually
329        {
330            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
331            reg.register("posts", "user_id", "users", "id");
332        }
333
334        // Test forward join: from users, join posts
335        // This should find reverse: posts.user_id -> users.id
336        let query = Qail::get("users").join_on("posts");
337
338        assert_eq!(query.joins.len(), 1);
339        let join = &query.joins[0];
340        assert_eq!(join.table, "posts");
341
342        // Verify join conditions
343        let on = join.on.as_ref().expect("Should have ON conditions");
344        assert_eq!(on.len(), 1);
345
346        // Clean up
347        {
348            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
349            *reg = RelationRegistry::new();
350        }
351    }
352
353    #[test]
354    fn test_join_on_optional_returns_self_when_no_relation() {
355        use crate::Qail;
356
357        // Clear registry
358        {
359            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
360            *reg = RelationRegistry::new();
361        }
362
363        // join_on_optional should not panic, just return self unchanged
364        let query = Qail::get("users").join_on_optional("nonexistent");
365        assert!(query.joins.is_empty());
366    }
367
368    #[test]
369    fn test_join_on_returns_self_when_no_relation() {
370        use crate::Qail;
371
372        {
373            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
374            *reg = RelationRegistry::new();
375        }
376
377        let query = Qail::get("users").join_on("nonexistent");
378        assert!(query.joins.is_empty());
379    }
380
381    #[test]
382    fn test_try_join_on_returns_error_when_no_relation() {
383        use crate::Qail;
384
385        {
386            let mut reg = super::RUNTIME_RELATIONS.write().unwrap();
387            *reg = RelationRegistry::new();
388        }
389
390        let err = Qail::get("users")
391            .try_join_on("nonexistent")
392            .expect_err("expected missing relation error");
393        assert!(err.contains("No relation found"));
394    }
395}
396
397use std::collections::HashMap;
398use std::sync::LazyLock;
399use std::sync::RwLock;
400
401/// Registry of table foreign-key relationships for auto-join inference.
402#[derive(Debug, Default)]
403pub struct RelationRegistry {
404    /// Forward lookups: (from_table, to_table) -> (from_col, to_col)
405    forward: HashMap<(String, String), (String, String)>,
406    /// Reverse lookups: to_table -> list of tables that reference it
407    reverse: HashMap<String, Vec<String>>,
408}
409
410impl RelationRegistry {
411    /// Create a new empty registry.
412    pub fn new() -> Self {
413        Self::default()
414    }
415
416    /// Register a foreign-key relation from schema.
417    ///
418    /// # Arguments
419    ///
420    /// * `from_table` — Source (referencing) table.
421    /// * `from_col` — Foreign-key column in the source table.
422    /// * `to_table` — Target (referenced) table.
423    /// * `to_col` — Primary-key column in the target table.
424    pub fn register(&mut self, from_table: &str, from_col: &str, to_table: &str, to_col: &str) {
425        self.forward.insert(
426            (from_table.to_string(), to_table.to_string()),
427            (from_col.to_string(), to_col.to_string()),
428        );
429
430        self.reverse
431            .entry(to_table.to_string())
432            .or_default()
433            .push(from_table.to_string());
434    }
435
436    /// Lookup join columns for a relation.
437    ///
438    /// Returns `(from_col, to_col)` if the relation exists.
439    ///
440    /// # Arguments
441    ///
442    /// * `from_table` — Source table name.
443    /// * `to_table` — Target table name.
444    pub fn get(&self, from_table: &str, to_table: &str) -> Option<(&str, &str)> {
445        self.forward
446            .get(&(from_table.to_string(), to_table.to_string()))
447            .map(|(a, b)| (a.as_str(), b.as_str()))
448    }
449
450    /// Get all tables that reference this table (for reverse joins).
451    pub fn referencing(&self, table: &str) -> Vec<&str> {
452        self.reverse
453            .get(table)
454            .map(|v| v.iter().map(|s| s.as_str()).collect())
455            .unwrap_or_default()
456    }
457
458    /// Load relations from a parsed build::Schema.
459    pub fn from_build_schema(schema: &crate::build::Schema) -> Self {
460        let mut registry = Self::new();
461
462        for table in schema.tables.values() {
463            for fk in &table.foreign_keys {
464                registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
465            }
466        }
467
468        registry
469    }
470}
471
472/// Global mutable registry for runtime schema loading.
473pub static RUNTIME_RELATIONS: LazyLock<RwLock<RelationRegistry>> =
474    LazyLock::new(|| RwLock::new(RelationRegistry::new()));
475
476/// Load relations from a schema.qail file into the runtime registry.
477/// Returns the number of relations loaded.
478pub fn load_schema_relations(path: &str) -> Result<usize, String> {
479    let schema = crate::build::Schema::parse_file(path)?;
480    let mut registry = RUNTIME_RELATIONS
481        .write()
482        .map_err(|e| format!("Lock error: {}", e))?;
483
484    let mut count = 0;
485    for table in schema.tables.values() {
486        for fk in &table.foreign_keys {
487            registry.register(&table.name, &fk.column, &fk.ref_table, &fk.ref_column);
488            count += 1;
489        }
490    }
491
492    Ok(count)
493}
494
495/// Lookup join info for implicit join.
496/// Returns (from_col, to_col) if relation exists.
497pub fn lookup_relation(from_table: &str, to_table: &str) -> Option<(String, String)> {
498    let registry = RUNTIME_RELATIONS.read().ok()?;
499    let (fc, tc) = registry.get(from_table, to_table)?;
500    Some((fc.to_string(), tc.to_string()))
501}