1use crate::build::Schema;
24use crate::migrate::types::ColumnType;
25use std::fs;
26
27pub 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
35pub 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
41pub fn generate_schema_code(schema: &Schema) -> String {
43 let mut code = String::new();
44
45 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 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 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 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 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
105fn 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 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 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"), };
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 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 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 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 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 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 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 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
248fn 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
271fn 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
303fn 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}