qail_core/migrate/
parser.rs

1//! QAIL Schema Parser
2//!
3//! Parses .qail text format into Schema AST.
4//!
5//! ## Grammar
6//! ```text
7//! schema = { table_def | index_def | migration_hint }*
8//!
9//! table_def = "table" IDENT "{" column_def* "}"
10//! column_def = IDENT TYPE constraint*
11//! constraint = "primary_key" | "not_null" | "nullable" | "unique" | "default" VALUE
12//!
13//! index_def = ["unique"] "index" IDENT "on" IDENT "(" IDENT+ ")"
14//!
15//! migration_hint = "rename" PATH "->" PATH
16//!                | "transform" EXPR "->" PATH
17//!                | "drop" PATH ["confirm"]
18//! ```
19
20use super::schema::{Column, Index, MigrationHint, Schema, Table};
21
22/// Parse a .qail file into a Schema.
23pub fn parse_qail(input: &str) -> Result<Schema, String> {
24    let mut schema = Schema::new();
25    let mut lines = input.lines().peekable();
26
27    while let Some(line) = lines.next() {
28        let line = line.trim();
29
30        // Skip empty lines, # comments, -- comments, and version directives
31        if line.is_empty() || line.starts_with('#') || line.starts_with("--") {
32            continue;
33        }
34
35        if line.starts_with("table ") {
36            let (table, consumed) = parse_table(line, &mut lines)?;
37            schema.add_table(table);
38            // consumed lines already processed
39            let _ = consumed;
40        }
41        else if line.starts_with("unique index ") || line.starts_with("index ") {
42            let index = parse_index(line)?;
43            schema.add_index(index);
44        }
45        else if line.starts_with("rename ") {
46            let hint = parse_rename(line)?;
47            schema.add_hint(hint);
48        } else if line.starts_with("transform ") {
49            let hint = parse_transform(line)?;
50            schema.add_hint(hint);
51        } else if line.starts_with("drop ") {
52            let hint = parse_drop(line)?;
53            schema.add_hint(hint);
54        } else {
55            return Err(format!("Unknown statement: {}", line));
56        }
57    }
58
59    Ok(schema)
60}
61
62/// Parse a table definition with columns.
63fn parse_table<'a, I>(
64    first_line: &str,
65    lines: &mut std::iter::Peekable<I>,
66) -> Result<(Table, usize), String>
67where
68    I: Iterator<Item = &'a str>,
69{
70    let rest = first_line.strip_prefix("table ").unwrap();
71    let name = rest.trim_end_matches('{').trim().to_string();
72
73    if name.is_empty() {
74        return Err("Table name required".to_string());
75    }
76
77    let mut table = Table::new(&name);
78    let mut consumed = 0;
79
80    for line in lines.by_ref() {
81        consumed += 1;
82        let line = line.trim();
83
84        if line == "}" || line.starts_with('}') {
85            break;
86        }
87
88        if line.is_empty() || line.starts_with('#') {
89            continue;
90        }
91
92        let col = parse_column(line)?;
93        table.columns.push(col);
94    }
95
96    Ok((table, consumed))
97}
98
99/// Parse a column definition.
100fn parse_column(line: &str) -> Result<Column, String> {
101    let parts: Vec<&str> = line.split_whitespace().collect();
102
103    if parts.len() < 2 {
104        return Err(format!("Invalid column: {}", line));
105    }
106
107    let name = parts[0].to_string();
108    let type_str = parts[1];
109
110    let data_type: super::types::ColumnType = type_str
111        .parse()
112        .map_err(|_| format!("Unknown column type: {}", type_str))?;
113
114    let mut col = Column::new(&name, data_type);
115
116    let mut i = 2;
117    while i < parts.len() {
118        match parts[i] {
119            "primary_key" => {
120                col.primary_key = true;
121                col.nullable = false;
122            }
123            "not_null" => {
124                col.nullable = false;
125            }
126            "nullable" => {
127                col.nullable = true;
128            }
129            "unique" => {
130                col.unique = true;
131            }
132            "default" => {
133                if i + 1 < parts.len() {
134                    col.default = Some(parts[i + 1].to_string());
135                    i += 1;
136                }
137            }
138            s if s.starts_with("references") => {
139                let fk_str = if s.contains('(') {
140                    // references is attached: "references users(id)"
141                    s.strip_prefix("references").unwrap_or(s)
142                } else if i + 1 < parts.len() {
143                    // references is separate: "references" "users(id)"
144                    i += 1;
145                    parts[i]
146                } else {
147                    ""
148                };
149
150                if let Some(paren_start) = fk_str.find('(')
151                    && let Some(paren_end) = fk_str.find(')')
152                {
153                    let table = &fk_str[..paren_start];
154                    let column = &fk_str[paren_start + 1..paren_end];
155                    col = col.references(table, column);
156                }
157            }
158            _ => {
159                // Unknown constraint, might be part of default value
160            }
161        }
162        i += 1;
163    }
164
165    Ok(col)
166}
167
168/// Parse an index definition.
169fn parse_index(line: &str) -> Result<Index, String> {
170    let is_unique = line.starts_with("unique ");
171    let rest = if is_unique {
172        line.strip_prefix("unique index ").unwrap()
173    } else {
174        line.strip_prefix("index ").unwrap()
175    };
176
177    let parts: Vec<&str> = rest.splitn(2, " on ").collect();
178    if parts.len() != 2 {
179        return Err(format!("Invalid index: {}", line));
180    }
181
182    let name = parts[0].trim().to_string();
183    let rest = parts[1];
184
185    let paren_start = rest.find('(').ok_or("Missing ( in index")?;
186    let paren_end = rest.find(')').ok_or("Missing ) in index")?;
187
188    let table = rest[..paren_start].trim().to_string();
189    let cols_str = &rest[paren_start + 1..paren_end];
190    let columns: Vec<String> = cols_str.split(',').map(|s| s.trim().to_string()).collect();
191
192    let mut index = Index::new(&name, &table, columns);
193    if is_unique {
194        index.unique = true;
195    }
196
197    Ok(index)
198}
199
200/// Parse a rename hint.
201fn parse_rename(line: &str) -> Result<MigrationHint, String> {
202    // rename users.username -> users.name
203    let rest = line.strip_prefix("rename ").unwrap();
204    let parts: Vec<&str> = rest.split(" -> ").collect();
205
206    if parts.len() != 2 {
207        return Err(format!("Invalid rename: {}", line));
208    }
209
210    Ok(MigrationHint::Rename {
211        from: parts[0].trim().to_string(),
212        to: parts[1].trim().to_string(),
213    })
214}
215
216/// Parse a transform hint.
217fn parse_transform(line: &str) -> Result<MigrationHint, String> {
218    // transform age * 12 -> age_months
219    let rest = line.strip_prefix("transform ").unwrap();
220    let parts: Vec<&str> = rest.split(" -> ").collect();
221
222    if parts.len() != 2 {
223        return Err(format!("Invalid transform: {}", line));
224    }
225
226    Ok(MigrationHint::Transform {
227        expression: parts[0].trim().to_string(),
228        target: parts[1].trim().to_string(),
229    })
230}
231
232/// Parse a drop hint.
233fn parse_drop(line: &str) -> Result<MigrationHint, String> {
234    // drop temp_table confirm
235    let rest = line.strip_prefix("drop ").unwrap();
236    let confirmed = rest.ends_with(" confirm");
237    let target = if confirmed {
238        rest.strip_suffix(" confirm").unwrap().trim().to_string()
239    } else {
240        rest.trim().to_string()
241    };
242
243    Ok(MigrationHint::Drop { target, confirmed })
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_parse_simple_table() {
252        let input = r#"
253table users {
254  id serial primary_key
255  name text not_null
256  email text nullable unique
257}
258"#;
259        let schema = parse_qail(input).unwrap();
260        assert!(schema.tables.contains_key("users"));
261        let table = &schema.tables["users"];
262        assert_eq!(table.columns.len(), 3);
263        assert!(table.columns[0].primary_key);
264        assert!(!table.columns[1].nullable);
265        assert!(table.columns[2].unique);
266    }
267
268    #[test]
269    fn test_parse_index() {
270        let input = "unique index idx_users_email on users (email)";
271        let schema = parse_qail(input).unwrap();
272        assert_eq!(schema.indexes.len(), 1);
273        assert!(schema.indexes[0].unique);
274        assert_eq!(schema.indexes[0].name, "idx_users_email");
275    }
276
277    #[test]
278    fn test_parse_rename() {
279        let input = "rename users.username -> users.name";
280        let schema = parse_qail(input).unwrap();
281        assert_eq!(schema.migrations.len(), 1);
282        assert!(matches!(
283            &schema.migrations[0],
284            MigrationHint::Rename { from, to } if from == "users.username" && to == "users.name"
285        ));
286    }
287
288    #[test]
289    fn test_parse_full_schema() {
290        let input = r#"
291# User table
292table users {
293  id serial primary_key
294  name text not_null
295  email text unique
296  created_at timestamptz default now()
297}
298
299unique index idx_users_email on users (email)
300
301rename users.username -> users.name
302"#;
303        let schema = parse_qail(input).unwrap();
304        assert_eq!(schema.tables.len(), 1);
305        assert_eq!(schema.indexes.len(), 1);
306        assert_eq!(schema.migrations.len(), 1);
307    }
308}