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}