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