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