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 serde::{Deserialize, Serialize};
24use crate::validator::Validator;
25
26/// Database schema definition.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct Schema {
29    pub tables: Vec<TableDef>,
30}
31
32/// Table definition with columns.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TableDef {
35    pub name: String,
36    pub columns: Vec<ColumnDef>,
37}
38
39/// Column definition with type information.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ColumnDef {
42    pub name: String,
43    #[serde(rename = "type", alias = "typ")]
44    pub typ: String,
45    #[serde(default)]
46    pub nullable: bool,
47    #[serde(default)]
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 JSON string.
73    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
74        serde_json::from_str(json)
75    }
76
77    /// Load schema from QAIL schema format (schema.qail).
78    /// 
79    /// Parses text like:
80    /// ```text
81    /// table users (
82    ///     id string not null,
83    ///     email string not null,
84    ///     created_at date
85    /// )
86    /// ```
87    pub fn from_qail_schema(input: &str) -> Result<Self, String> {
88        let mut schema = Schema::new();
89        let mut current_table: Option<TableDef> = None;
90        
91        for line in input.lines() {
92            let line = line.trim();
93            
94            // Skip empty lines and comments
95            if line.is_empty() || line.starts_with("--") {
96                continue;
97            }
98            
99            // Match "table tablename ("
100            if line.starts_with("table ") {
101                // Save previous table if any
102                if let Some(t) = current_table.take() {
103                    schema.tables.push(t);
104                }
105                
106                // Parse table name: "table users (" -> "users"
107                let rest = &line[6..]; // Skip "table "
108                let name = rest.split('(').next()
109                    .map(|s| s.trim())
110                    .ok_or_else(|| format!("Invalid table line: {}", line))?;
111                
112                current_table = Some(TableDef::new(name));
113            }
114            // Match closing paren
115            else if line == ")" {
116                if let Some(t) = current_table.take() {
117                    schema.tables.push(t);
118                }
119            }
120            // Match column definition: "name type [not null],"
121            else if let Some(ref mut table) = current_table {
122                // Remove trailing comma
123                let line = line.trim_end_matches(',');
124                
125                let parts: Vec<&str> = line.split_whitespace().collect();
126                if parts.len() >= 2 {
127                    let col_name = parts[0];
128                    let col_type = parts[1];
129                    let not_null = parts.len() > 2 && 
130                        parts.iter().any(|&p| p.eq_ignore_ascii_case("not")) &&
131                        parts.iter().any(|&p| p.eq_ignore_ascii_case("null"));
132                    
133                    table.columns.push(ColumnDef {
134                        name: col_name.to_string(),
135                        typ: col_type.to_string(),
136                        nullable: !not_null,
137                        primary_key: false,
138                    });
139                }
140            }
141        }
142        
143        // Don't forget the last table
144        if let Some(t) = current_table {
145            schema.tables.push(t);
146        }
147        
148        Ok(schema)
149    }
150
151    /// Load schema from file path (auto-detects format).
152    pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
153        let content = std::fs::read_to_string(path)
154            .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
155        
156        // Detect format: .json -> JSON, else -> QAIL schema
157        if path.extension().map(|e| e == "json").unwrap_or(false) {
158            Self::from_json(&content).map_err(|e| e.to_string())
159        } else {
160            Self::from_qail_schema(&content)
161        }
162    }
163}
164
165impl Default for Schema {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl TableDef {
172    /// Create a new table definition.
173    pub fn new(name: &str) -> Self {
174        Self {
175            name: name.to_string(),
176            columns: Vec::new(),
177        }
178    }
179
180    /// Add a column to the table.
181    pub fn add_column(&mut self, col: ColumnDef) {
182        self.columns.push(col);
183    }
184
185    /// Builder: add a simple column.
186    pub fn column(mut self, name: &str, typ: &str) -> Self {
187        self.columns.push(ColumnDef {
188            name: name.to_string(),
189            typ: typ.to_string(),
190            nullable: true,
191            primary_key: false,
192        });
193        self
194    }
195
196    /// Builder: add a primary key column.
197    pub fn pk(mut self, name: &str, typ: &str) -> Self {
198        self.columns.push(ColumnDef {
199            name: name.to_string(),
200            typ: typ.to_string(),
201            nullable: false,
202            primary_key: true,
203        });
204        self
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_schema_from_json() {
214        let json = r#"{
215            "tables": [{
216                "name": "users",
217                "columns": [
218                    { "name": "id", "type": "uuid", "nullable": false, "primary_key": true },
219                    { "name": "email", "type": "varchar", "nullable": false }
220                ]
221            }]
222        }"#;
223
224        let schema = Schema::from_json(json).unwrap();
225        assert_eq!(schema.tables.len(), 1);
226        assert_eq!(schema.tables[0].name, "users");
227        assert_eq!(schema.tables[0].columns.len(), 2);
228    }
229
230    #[test]
231    fn test_schema_to_validator() {
232        let schema = Schema {
233            tables: vec![
234                TableDef::new("users").pk("id", "uuid").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}