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)
32        .map_err(|e| format!("Failed to write output: {}", e))?;
33    Ok(())
34}
35
36/// Generate typed Rust code from a schema.qail file
37pub fn generate_from_file(schema_path: &str) -> Result<String, String> {
38    let schema = Schema::parse_file(schema_path)?;
39    Ok(generate_schema_code(&schema))
40}
41
42/// Generate Rust code for the schema
43pub fn generate_schema_code(schema: &Schema) -> String {
44    let mut code = String::new();
45    
46    // Header
47    code.push_str("//! Auto-generated by `qail types`\n");
48    code.push_str("//! Do not edit manually.\n\n");
49    code.push_str("#![allow(dead_code)]\n\n");
50    code.push_str("use qail_core::typed::{Table, TypedColumn, RequiresRls, DirectBuild, Bucket, Queue, Topic};\n\n");
51    
52    // Generate table modules
53    let mut table_names: Vec<_> = schema.tables.keys().collect();
54    table_names.sort();
55    
56    for table_name in &table_names {
57        if let Some(table) = schema.tables.get(*table_name) {
58            code.push_str(&generate_table_module(table_name, table));
59            code.push('\n');
60        }
61    }
62    
63    // Generate tables re-export
64    code.push_str("/// Re-export all table types\n");
65    code.push_str("pub mod tables {\n");
66    
67    for table_name in &table_names {
68        let struct_name = to_pascal_case(table_name);
69        code.push_str(&format!(
70            "    pub use super::{}::{};\n",
71            table_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 struct_name = to_pascal_case(res_name);
93            code.push_str(&format!(
94                "    pub use super::{}::{};\n",
95                res_name, struct_name
96            ));
97        }
98        code.push_str("}\n");
99    }
100    
101    code
102}
103
104/// Generate a module for an infrastructure resource
105fn generate_resource_module(resource_name: &str, resource: &crate::build::ResourceSchema) -> String {
106    let mut code = String::new();
107    let struct_name = to_pascal_case(resource_name);
108    let kind = &resource.kind;
109    
110    code.push_str(&format!("/// {} resource: {}\n", kind, resource_name));
111    code.push_str(&format!("pub mod {} {{\n", resource_name));
112    code.push_str("    use super::*;\n\n");
113    
114    // Struct
115    code.push_str(&format!("    /// Type-safe reference to {} `{}`\n", kind, resource_name));
116    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
117    code.push_str(&format!("    pub struct {};\n\n", struct_name));
118    
119    // Implement the appropriate trait
120    let (trait_name, method_name) = match kind.as_str() {
121        "bucket" => ("Bucket", "bucket_name"),
122        "queue" => ("Queue", "queue_name"),
123        "topic" => ("Topic", "topic_name"),
124        _ => ("Bucket", "bucket_name"), // fallback
125    };
126    
127    code.push_str(&format!("    impl {} for {} {{\n", trait_name, struct_name));
128    code.push_str(&format!(
129        "        fn {}() -> &'static str {{ \"{}\" }}\n",
130        method_name, resource_name
131    ));
132    code.push_str("    }\n");
133    
134    // Add provider constant if specified
135    if let Some(ref provider) = resource.provider {
136        code.push_str(&format!("\n    pub const PROVIDER: &str = \"{}\";\n", provider));
137    }
138    
139    // Add property constants
140    for (key, value) in &resource.properties {
141        let const_name = key.to_uppercase();
142        code.push_str(&format!("    pub const {}: &str = \"{}\";\n", const_name, value));
143    }
144    
145    code.push_str("}\n");
146    code
147}
148
149fn generate_table_module(table_name: &str, table: &crate::build::TableSchema) -> String {
150    let mut code = String::new();
151    let struct_name = to_pascal_case(table_name);
152    
153    code.push_str(&format!("/// Table: {}\n", table_name));
154    code.push_str(&format!("pub mod {} {{\n", table_name));
155    code.push_str("    use super::*;\n\n");
156    
157    // Table struct with Table trait
158    code.push_str(&format!("    /// Type-safe reference to `{}`\n", table_name));
159    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
160    code.push_str(&format!("    pub struct {};\n\n", struct_name));
161    
162    code.push_str(&format!("    impl Table for {} {{\n", struct_name));
163    code.push_str(&format!(
164        "        fn table_name() -> &'static str {{ \"{}\" }}\n",
165        table_name
166    ));
167    code.push_str("    }\n\n");
168    
169    // Implement From<Table> for String to work with Qail::get()
170    code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
171    code.push_str(&format!("        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table_name));
172    code.push_str("    }\n\n");
173    
174    // AsRef<str> for TypedQail compatibility
175    code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
176    code.push_str(&format!("        fn as_ref(&self) -> &str {{ \"{}\" }}\n", table_name));
177    code.push_str("    }\n\n");
178    
179    // RLS trait: RequiresRls for tables with operator_id, DirectBuild for others
180    if table.rls_enabled {
181        code.push_str("    /// This table has `operator_id` — queries require `.with_rls()` proof\n");
182        code.push_str(&format!("    impl RequiresRls for {} {{}}\n\n", struct_name));
183    } else {
184        code.push_str(&format!("    impl DirectBuild for {} {{}}\n\n", struct_name));
185    }
186    
187    // Typed column functions
188    let mut col_names: Vec<_> = table.columns.keys().collect();
189    col_names.sort();
190    
191    for col_name in &col_names {
192        if let Some(col_type) = table.columns.get(*col_name) {
193            let rust_type = column_type_to_rust(col_type);
194            let fn_name = escape_keyword(col_name);
195            code.push_str(&format!(
196                "    /// Column `{}` ({})\n",
197                col_name, col_type.to_pg_type()
198            ));
199            code.push_str(&format!(
200                "    pub fn {}() -> TypedColumn<{}> {{ TypedColumn::new(\"{}\", \"{}\") }}\n\n",
201                fn_name, rust_type, table_name, col_name
202            ));
203        }
204    }
205    
206    code.push_str("}\n");
207    
208    code
209}
210
211/// Map ColumnType AST enum to Rust types (for codegen).
212/// This is the ONLY place where we map SQL types to Rust types.
213fn column_type_to_rust(col_type: &ColumnType) -> &'static str {
214    match col_type {
215        ColumnType::Uuid => "uuid::Uuid",
216        ColumnType::Text | ColumnType::Varchar(_) => "String",
217        ColumnType::Int | ColumnType::BigInt | ColumnType::Serial | ColumnType::BigSerial => "i64",
218        ColumnType::Bool => "bool",
219        ColumnType::Float | ColumnType::Decimal(_) => "f64",
220        ColumnType::Jsonb => "serde_json::Value",
221        ColumnType::Timestamp | ColumnType::Timestamptz | ColumnType::Date | ColumnType::Time => "chrono::DateTime<chrono::Utc>",
222        ColumnType::Bytea => "Vec<u8>",
223        ColumnType::Array(_) => "Vec<serde_json::Value>",
224        ColumnType::Enum { .. } => "String",
225        ColumnType::Range(_) => "String",
226        ColumnType::Interval => "String",
227        ColumnType::Cidr | ColumnType::Inet => "String",
228        ColumnType::MacAddr => "String",
229    }
230}
231
232/// Convert snake_case to PascalCase
233fn to_pascal_case(s: &str) -> String {
234    s.split('_')
235        .map(|word| {
236            let mut chars = word.chars();
237            match chars.next() {
238                None => String::new(),
239                Some(c) => c.to_uppercase().chain(chars).collect(),
240            }
241        })
242        .collect()
243}
244
245/// Escape Rust reserved keywords with r# prefix
246fn escape_keyword(name: &str) -> String {
247    const KEYWORDS: &[&str] = &[
248        "as", "break", "const", "continue", "crate", "else", "enum", "extern",
249        "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod",
250        "move", "mut", "pub", "ref", "return", "self", "Self", "static", "struct",
251        "super", "trait", "true", "type", "unsafe", "use", "where", "while",
252        "async", "await", "dyn", "abstract", "become", "box", "do", "final",
253        "macro", "override", "priv", "try", "typeof", "unsized", "virtual", "yield",
254    ];
255    
256    if KEYWORDS.contains(&name) {
257        format!("r#{}", name)
258    } else {
259        name.to_string()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    
267    #[test]
268    fn test_pascal_case() {
269        assert_eq!(to_pascal_case("users"), "Users");
270        assert_eq!(to_pascal_case("user_profiles"), "UserProfiles");
271    }
272    
273    #[test]
274    fn test_column_type_mapping() {
275        assert_eq!(column_type_to_rust(&ColumnType::Int), "i64");
276        assert_eq!(column_type_to_rust(&ColumnType::Text), "String");
277        assert_eq!(column_type_to_rust(&ColumnType::Uuid), "uuid::Uuid");
278        assert_eq!(column_type_to_rust(&ColumnType::Bool), "bool");
279        assert_eq!(column_type_to_rust(&ColumnType::Jsonb), "serde_json::Value");
280        assert_eq!(column_type_to_rust(&ColumnType::BigInt), "i64");
281        assert_eq!(column_type_to_rust(&ColumnType::Float), "f64");
282        assert_eq!(column_type_to_rust(&ColumnType::Timestamp), "chrono::DateTime<chrono::Utc>");
283        assert_eq!(column_type_to_rust(&ColumnType::Bytea), "Vec<u8>");
284    }
285}