Skip to main content

qail_core/
codegen.rs

1//! Type-safe schema code generation.
2//!
3//! Generates Rust code from schema.qail for compile-time type safety.
4//!
5//! # Usage from build.rs
6//! ```ignore
7//! qail_core::codegen::generate_to_file("schema.qail", "src/generated/schema.rs")?;
8//! ```
9//!
10//! # Generated code example
11//! ```ignore
12//! pub mod users {
13//!     use qail_core::typed::{Table, TypedColumn};
14//!     
15//!     pub struct Users;
16//!     impl Table for Users { fn table_name() -> &'static str { "users" } }
17//!     
18//!     pub fn id() -> TypedColumn<uuid::Uuid> { TypedColumn::new("users", "id") }
19//!     pub fn age() -> TypedColumn<i64> { TypedColumn::new("users", "age") }
20//! }
21//! ```
22
23use crate::build::Schema;
24use crate::migrate::types::ColumnType;
25use std::fs;
26
27/// Generate typed Rust code from a schema.qail file and write to output
28pub fn generate_to_file(schema_path: &str, output_path: &str) -> Result<(), String> {
29    let schema = Schema::parse_file(schema_path)?;
30    let code = generate_schema_code(&schema);
31    fs::write(output_path, &code).map_err(|e| format!("Failed to write output: {}", e))?;
32    Ok(())
33}
34
35/// Generate typed Rust code from a schema.qail file
36pub fn generate_from_file(schema_path: &str) -> Result<String, String> {
37    let schema = Schema::parse_file(schema_path)?;
38    Ok(generate_schema_code(&schema))
39}
40
41/// Generate Rust code for the schema
42pub fn generate_schema_code(schema: &Schema) -> String {
43    let mut code = String::new();
44
45    // Header
46    code.push_str("//! Auto-generated by `qail types`\n");
47    code.push_str("//! Do not edit manually.\n\n");
48    code.push_str("#![allow(dead_code)]\n\n");
49    code.push_str("use qail_core::typed::{Table, TypedColumn, RequiresRls, DirectBuild, Bucket, Queue, Topic};\n\n");
50
51    // Generate table modules
52    let mut table_names: Vec<_> = schema.tables.keys().collect();
53    table_names.sort();
54
55    for table_name in &table_names {
56        if let Some(table) = schema.tables.get(*table_name) {
57            code.push_str(&generate_table_module(table_name, table));
58            code.push('\n');
59        }
60    }
61
62    // Generate tables re-export
63    code.push_str("/// Re-export all table types\n");
64    code.push_str("pub mod tables {\n");
65
66    for table_name in &table_names {
67        let module_name = to_rust_ident(table_name);
68        let struct_name = to_pascal_case(table_name);
69        code.push_str(&format!(
70            "    pub use super::{}::{};\n",
71            module_name, struct_name
72        ));
73    }
74    code.push_str("}\n\n");
75
76    // Generate resource modules
77    let mut resource_names: Vec<_> = schema.resources.keys().collect();
78    resource_names.sort();
79
80    for res_name in &resource_names {
81        if let Some(resource) = schema.resources.get(*res_name) {
82            code.push_str(&generate_resource_module(res_name, resource));
83            code.push('\n');
84        }
85    }
86
87    // Generate resources re-export
88    if !resource_names.is_empty() {
89        code.push_str("/// Re-export all resource types\n");
90        code.push_str("pub mod resources {\n");
91        for res_name in &resource_names {
92            let module_name = to_rust_ident(res_name);
93            let struct_name = to_pascal_case(res_name);
94            code.push_str(&format!(
95                "    pub use super::{}::{};\n",
96                module_name, struct_name
97            ));
98        }
99        code.push_str("}\n");
100    }
101
102    code
103}
104
105/// Generate a module for an infrastructure resource
106fn generate_resource_module(
107    resource_name: &str,
108    resource: &crate::build::ResourceSchema,
109) -> String {
110    let mut code = String::new();
111    let module_name = to_rust_ident(resource_name);
112    let struct_name = to_pascal_case(resource_name);
113    let kind = &resource.kind;
114
115    code.push_str(&format!("/// {} resource: {}\n", kind, resource_name));
116    code.push_str(&format!("pub mod {} {{\n", module_name));
117    code.push_str("    use super::*;\n\n");
118
119    // Struct
120    code.push_str(&format!(
121        "    /// Type-safe reference to {} `{}`\n",
122        kind, resource_name
123    ));
124    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
125    code.push_str(&format!("    pub struct {};\n\n", struct_name));
126
127    // Implement the appropriate trait
128    let (trait_name, method_name) = match kind.as_str() {
129        "bucket" => ("Bucket", "bucket_name"),
130        "queue" => ("Queue", "queue_name"),
131        "topic" => ("Topic", "topic_name"),
132        _ => ("Bucket", "bucket_name"), // fallback
133    };
134
135    code.push_str(&format!("    impl {} for {} {{\n", trait_name, struct_name));
136    code.push_str(&format!(
137        "        fn {}() -> &'static str {{ {} }}\n",
138        method_name,
139        rust_string_literal(resource_name)
140    ));
141    code.push_str("    }\n");
142
143    // Add provider constant if specified
144    if let Some(ref provider) = resource.provider {
145        code.push_str(&format!(
146            "\n    pub const PROVIDER: &str = {};\n",
147            rust_string_literal(provider)
148        ));
149    }
150
151    // Add property constants
152    for (key, value) in &resource.properties {
153        let const_name = to_const_ident(key);
154        code.push_str(&format!(
155            "    pub const {}: &str = {};\n",
156            const_name,
157            rust_string_literal(value)
158        ));
159    }
160
161    code.push_str("}\n");
162    code
163}
164
165fn generate_table_module(table_name: &str, table: &crate::build::TableSchema) -> String {
166    let mut code = String::new();
167    let module_name = to_rust_ident(table_name);
168    let struct_name = to_pascal_case(table_name);
169
170    code.push_str(&format!("/// Table: {}\n", table_name));
171    code.push_str(&format!("pub mod {} {{\n", module_name));
172    code.push_str("    use super::*;\n\n");
173
174    // Table struct with Table trait
175    code.push_str(&format!(
176        "    /// Type-safe reference to `{}`\n",
177        table_name
178    ));
179    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
180    code.push_str(&format!("    pub struct {};\n\n", struct_name));
181
182    code.push_str(&format!("    impl Table for {} {{\n", struct_name));
183    code.push_str(&format!(
184        "        fn table_name() -> &'static str {{ {} }}\n",
185        rust_string_literal(table_name)
186    ));
187    code.push_str("    }\n\n");
188
189    // Implement From<Table> for String to work with Qail::get()
190    code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
191    code.push_str(&format!(
192        "        fn from(_: {}) -> String {{ {}.to_string() }}\n",
193        struct_name,
194        rust_string_literal(table_name)
195    ));
196    code.push_str("    }\n\n");
197
198    // AsRef<str> for TypedQail compatibility
199    code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
200    code.push_str(&format!(
201        "        fn as_ref(&self) -> &str {{ {} }}\n",
202        rust_string_literal(table_name)
203    ));
204    code.push_str("    }\n\n");
205
206    // RLS trait: RequiresRls for tables with tenant_id, DirectBuild for others
207    if table.rls_enabled {
208        code.push_str("    /// This table has `tenant_id` — queries require `.with_rls()` proof\n");
209        code.push_str(&format!(
210            "    impl RequiresRls for {} {{}}\n\n",
211            struct_name
212        ));
213    } else {
214        code.push_str(&format!(
215            "    impl DirectBuild for {} {{}}\n\n",
216            struct_name
217        ));
218    }
219
220    // Typed column functions
221    let mut col_names: Vec<_> = table.columns.keys().collect();
222    col_names.sort();
223
224    for col_name in &col_names {
225        if let Some(col_type) = table.columns.get(*col_name) {
226            let rust_type = column_type_to_rust(col_type);
227            let fn_name = to_rust_ident(col_name);
228            code.push_str(&format!(
229                "    /// Column `{}` ({})\n",
230                col_name,
231                col_type.to_pg_type()
232            ));
233            code.push_str(&format!(
234                "    pub fn {}() -> TypedColumn<{}> {{ TypedColumn::new({}, {}) }}\n\n",
235                fn_name,
236                rust_type,
237                rust_string_literal(table_name),
238                rust_string_literal(col_name)
239            ));
240        }
241    }
242
243    code.push_str("}\n");
244
245    code
246}
247
248/// Map ColumnType AST enum to Rust types (for codegen).
249/// This is the ONLY place where we map SQL types to Rust types.
250fn column_type_to_rust(col_type: &ColumnType) -> &'static str {
251    match col_type {
252        ColumnType::Uuid => "uuid::Uuid",
253        ColumnType::Text | ColumnType::Varchar(_) => "String",
254        ColumnType::Int | ColumnType::BigInt | ColumnType::Serial | ColumnType::BigSerial => "i64",
255        ColumnType::Bool => "bool",
256        ColumnType::Float | ColumnType::Decimal(_) => "f64",
257        ColumnType::Jsonb => "serde_json::Value",
258        ColumnType::Timestamp | ColumnType::Timestamptz | ColumnType::Date | ColumnType::Time => {
259            "chrono::DateTime<chrono::Utc>"
260        }
261        ColumnType::Bytea => "Vec<u8>",
262        ColumnType::Array(_) => "Vec<serde_json::Value>",
263        ColumnType::Enum { .. } => "String",
264        ColumnType::Range(_) => "String",
265        ColumnType::Interval => "String",
266        ColumnType::Cidr | ColumnType::Inet => "String",
267        ColumnType::MacAddr => "String",
268    }
269}
270
271/// Convert snake_case to PascalCase
272fn to_pascal_case(s: &str) -> String {
273    let pascal: String = s
274        .split(|c: char| !c.is_ascii_alphanumeric())
275        .filter(|word| !word.is_empty())
276        .map(|word| {
277            let mut chars = word.chars();
278            match chars.next() {
279                None => String::new(),
280                Some(c) => c.to_uppercase().chain(chars).collect(),
281            }
282        })
283        .collect();
284
285    let mut pascal = if pascal.is_empty() {
286        "QailGenerated".to_string()
287    } else {
288        pascal
289    };
290    if pascal
291        .chars()
292        .next()
293        .is_none_or(|c| !c.is_ascii_alphabetic() && c != '_')
294    {
295        pascal.insert_str(0, "Qail");
296    }
297    if is_rust_keyword(&pascal) {
298        pascal.insert_str(0, "Qail");
299    }
300    pascal
301}
302
303/// Escape Rust reserved keywords with r# prefix
304fn escape_keyword(name: &str) -> String {
305    if is_rust_keyword(name) {
306        format!("r#{}", name)
307    } else {
308        name.to_string()
309    }
310}
311
312fn is_rust_keyword(name: &str) -> bool {
313    const KEYWORDS: &[&str] = &[
314        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
315        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
316        "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
317        "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do",
318        "final", "macro", "override", "priv", "try", "typeof", "unsized", "virtual", "yield",
319    ];
320
321    KEYWORDS.contains(&name)
322}
323
324fn sanitize_rust_ident(name: &str) -> String {
325    let mut ident: String = name
326        .chars()
327        .map(|c| {
328            if c.is_ascii_alphanumeric() || c == '_' {
329                c
330            } else {
331                '_'
332            }
333        })
334        .collect();
335
336    if ident.is_empty() {
337        ident.push('_');
338    }
339    if ident
340        .chars()
341        .next()
342        .is_none_or(|c| !c.is_ascii_alphabetic() && c != '_')
343    {
344        ident.insert(0, '_');
345    }
346
347    ident
348}
349
350fn to_rust_ident(name: &str) -> String {
351    escape_keyword(&sanitize_rust_ident(name))
352}
353
354fn to_const_ident(name: &str) -> String {
355    to_rust_ident(&name.to_ascii_uppercase())
356}
357
358fn rust_string_literal(value: &str) -> String {
359    format!("{value:?}")
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_pascal_case() {
368        assert_eq!(to_pascal_case("users"), "Users");
369        assert_eq!(to_pascal_case("user_profiles"), "UserProfiles");
370        assert_eq!(to_pascal_case("123_events"), "Qail123Events");
371        assert_eq!(to_pascal_case("self"), "QailSelf");
372    }
373
374    #[test]
375    fn test_rust_identifier_sanitizing() {
376        assert_eq!(to_rust_ident("type"), "r#type");
377        assert_eq!(to_rust_ident("123abc"), "_123abc");
378        assert_eq!(to_rust_ident("user-files"), "user_files");
379    }
380
381    #[test]
382    fn test_column_type_mapping() {
383        assert_eq!(column_type_to_rust(&ColumnType::Int), "i64");
384        assert_eq!(column_type_to_rust(&ColumnType::Text), "String");
385        assert_eq!(column_type_to_rust(&ColumnType::Uuid), "uuid::Uuid");
386        assert_eq!(column_type_to_rust(&ColumnType::Bool), "bool");
387        assert_eq!(column_type_to_rust(&ColumnType::Jsonb), "serde_json::Value");
388        assert_eq!(column_type_to_rust(&ColumnType::BigInt), "i64");
389        assert_eq!(column_type_to_rust(&ColumnType::Float), "f64");
390        assert_eq!(
391            column_type_to_rust(&ColumnType::Timestamp),
392            "chrono::DateTime<chrono::Utc>"
393        );
394        assert_eq!(column_type_to_rust(&ColumnType::Bytea), "Vec<u8>");
395    }
396
397    #[test]
398    fn test_generate_schema_code_sanitizes_rust_identifiers() {
399        let schema_content = r#"
400table type {
401    1st TEXT
402    match TEXT
403}
404"#;
405
406        let schema = Schema::parse(schema_content).unwrap();
407        let code = generate_schema_code(&schema);
408
409        assert!(code.contains("pub mod r#type {"));
410        assert!(code.contains("pub struct Type;"));
411        assert!(code.contains("pub fn _1st()"));
412        assert!(code.contains("pub fn r#match()"));
413        assert!(code.contains("TypedColumn::new(\"type\", \"1st\")"));
414    }
415}