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