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