1use std::fmt::Write as _;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ColumnSchema {
39 pub name: String,
41 pub rust_type: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TableSchema {
48 pub name: String,
50 pub columns: Vec<ColumnSchema>,
52}
53
54pub struct SchemaGenerator {
56 header: String,
58 emit_use: bool,
60}
61
62impl Default for SchemaGenerator {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl SchemaGenerator {
69 pub fn new() -> Self {
71 Self {
72 header: format!(
73 "// Auto-generated by sz-orm-cli generate schema at {}\n\
74 // DO NOT EDIT MANUALLY — re-run the command to refresh.\n\
75 //\n\
76 // This file contains typed_query! table declarations\n\
77 // enabling compile-time column-name verification.\n",
78 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
79 ),
80 emit_use: true,
81 }
82 }
83
84 pub fn with_header(mut self, header: impl Into<String>) -> Self {
86 self.header = header.into();
87 self
88 }
89
90 pub fn emit_use(mut self, emit: bool) -> Self {
92 self.emit_use = emit;
93 self
94 }
95
96 pub fn generate(&self, tables: &[TableSchema]) -> String {
98 let mut out = String::new();
99
100 writeln!(out, "{}", self.header).unwrap();
102 writeln!(out).unwrap();
103
104 if self.emit_use {
106 writeln!(out, "use sz_orm_core::typed_query;").unwrap();
107 writeln!(out).unwrap();
108 }
109
110 for (idx, table) in tables.iter().enumerate() {
112 if idx > 0 {
113 writeln!(out).unwrap();
114 }
115 write!(out, "{}", self.render_table(table)).unwrap();
116 }
117
118 out
119 }
120
121 fn render_table(&self, table: &TableSchema) -> String {
123 let mut out = String::new();
124 writeln!(out, "typed_query! {{").unwrap();
125 writeln!(out, " table {} {{", table.name).unwrap();
126 for col in &table.columns {
127 writeln!(out, " {}: {},", col.name, col.rust_type).unwrap();
128 }
129 writeln!(out, " }}").unwrap();
130 writeln!(out, "}}").unwrap();
131 out
132 }
133}
134
135pub fn sql_type_to_rust(sql_type: &str, nullable: bool) -> String {
139 let base = match sql_type.to_lowercase() {
140 s if s.contains("tinyint") => "i8",
142 s if s.contains("smallint") || s.contains("int2") => "i16",
143 s if s.contains("bigint") || s.contains("int8") => "i64",
144 s if s.contains("int") || s.contains("serial") => "i32",
145 s if s.contains("float8") || s.contains("double") => "f64",
147 s if s.contains("float4") || s.contains("real") => "f32",
148 s if s.contains("float") => "f32",
149 s if s.contains("decimal") || s.contains("numeric") => "f64",
150 s if s.contains("bool") => "bool",
152 s if s.contains("blob") || s.contains("bytea") || s.contains("binary") => "Vec<u8>",
154 s if s.contains("date") && !s.contains("datetime") && !s.contains("timestamp") => "String",
156 s if s.contains("datetime") || s.contains("timestamp") => "String",
157 s if s.contains("time") => "String",
158 s if s.contains("json") => "String",
160 s if s.contains("uuid") => "String",
162 s if s.contains("char") || s.contains("text") || s.contains("varchar") => "String",
164 _ => "String",
166 };
167
168 if nullable {
169 format!("Option<{}>", base)
170 } else {
171 base.to_string()
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn test_sql_type_to_rust_int() {
181 assert_eq!(sql_type_to_rust("INT", false), "i32");
182 assert_eq!(sql_type_to_rust("BIGINT", false), "i64");
183 assert_eq!(sql_type_to_rust("SMALLINT", false), "i16");
184 assert_eq!(sql_type_to_rust("TINYINT", false), "i8");
185 }
186
187 #[test]
188 fn test_sql_type_to_rust_float() {
189 assert_eq!(sql_type_to_rust("FLOAT", false), "f32");
190 assert_eq!(sql_type_to_rust("DOUBLE", false), "f64");
191 assert_eq!(sql_type_to_rust("DECIMAL(10,2)", false), "f64");
192 }
193
194 #[test]
195 fn test_sql_type_to_rust_bool() {
196 assert_eq!(sql_type_to_rust("BOOLEAN", false), "bool");
197 assert_eq!(sql_type_to_rust("TINYINT(1)", false), "i8");
198 }
199
200 #[test]
201 fn test_sql_type_to_rust_string() {
202 assert_eq!(sql_type_to_rust("VARCHAR(255)", false), "String");
203 assert_eq!(sql_type_to_rust("TEXT", false), "String");
204 assert_eq!(sql_type_to_rust("CHAR(36)", false), "String");
205 }
206
207 #[test]
208 fn test_sql_type_to_rust_binary() {
209 assert_eq!(sql_type_to_rust("BLOB", false), "Vec<u8>");
210 assert_eq!(sql_type_to_rust("BYTEA", false), "Vec<u8>");
211 }
212
213 #[test]
214 fn test_sql_type_to_rust_nullable() {
215 assert_eq!(sql_type_to_rust("INT", true), "Option<i32>");
216 assert_eq!(sql_type_to_rust("VARCHAR(255)", true), "Option<String>");
217 }
218
219 #[test]
220 fn test_sql_type_to_rust_pg_types() {
221 assert_eq!(sql_type_to_rust("int8", false), "i64");
222 assert_eq!(sql_type_to_rust("int2", false), "i16");
223 assert_eq!(sql_type_to_rust("float8", false), "f64");
224 assert_eq!(sql_type_to_rust("float4", false), "f32");
225 assert_eq!(sql_type_to_rust("bytea", false), "Vec<u8>");
226 }
227
228 #[test]
229 fn test_sql_type_to_rust_json_uuid() {
230 assert_eq!(sql_type_to_rust("JSON", false), "String");
231 assert_eq!(sql_type_to_rust("JSONB", false), "String");
232 assert_eq!(sql_type_to_rust("UUID", false), "String");
233 }
234
235 #[test]
236 fn test_schema_generator_single_table() {
237 let gen = SchemaGenerator::new().emit_use(false);
238 let tables = vec![TableSchema {
239 name: "users".to_string(),
240 columns: vec![
241 ColumnSchema {
242 name: "id".to_string(),
243 rust_type: "i64".to_string(),
244 },
245 ColumnSchema {
246 name: "name".to_string(),
247 rust_type: "String".to_string(),
248 },
249 ],
250 }];
251 let output = gen.generate(&tables);
252
253 assert!(output.contains("table users {"));
254 assert!(output.contains("id: i64,"));
255 assert!(output.contains("name: String,"));
256 assert!(output.contains("typed_query! {"));
257 }
258
259 #[test]
260 fn test_schema_generator_multiple_tables() {
261 let gen = SchemaGenerator::new().emit_use(false);
262 let tables = vec![
263 TableSchema {
264 name: "users".to_string(),
265 columns: vec![ColumnSchema {
266 name: "id".to_string(),
267 rust_type: "i64".to_string(),
268 }],
269 },
270 TableSchema {
271 name: "orders".to_string(),
272 columns: vec![ColumnSchema {
273 name: "order_id".to_string(),
274 rust_type: "i64".to_string(),
275 }],
276 },
277 ];
278 let output = gen.generate(&tables);
279
280 assert!(output.contains("table users {"));
281 assert!(output.contains("table orders {"));
282 let users_end = output.find("}").unwrap();
284 let orders_start = output.find("table orders").unwrap();
285 let between = &output[users_end..orders_start];
286 assert!(between.contains("\n\n"));
287 }
288
289 #[test]
290 fn test_schema_generator_with_use_statement() {
291 let gen = SchemaGenerator::new().emit_use(true);
292 let tables = vec![TableSchema {
293 name: "t".to_string(),
294 columns: vec![],
295 }];
296 let output = gen.generate(&tables);
297
298 assert!(output.contains("use sz_orm_core::typed_query;"));
299 }
300
301 #[test]
302 fn test_schema_generator_header() {
303 let gen = SchemaGenerator::new();
304 let output = gen.generate(&[]);
305 assert!(output.contains("Auto-generated"));
306 assert!(output.contains("DO NOT EDIT MANUALLY"));
307 }
308
309 #[test]
310 fn test_schema_generator_custom_header() {
311 let gen = SchemaGenerator::new().with_header("// Custom header\n");
312 let output = gen.generate(&[]);
313 assert!(output.starts_with("// Custom header"));
314 assert!(!output.contains("Auto-generated"));
315 }
316
317 #[test]
318 fn test_schema_generator_empty_tables() {
319 let gen = SchemaGenerator::new().emit_use(false);
320 let output = gen.generate(&[]);
321 assert!(output.contains("Auto-generated"));
323 assert!(!output.contains("typed_query! {"));
326 }
327
328 #[test]
329 fn test_schema_generator_option_type() {
330 let gen = SchemaGenerator::new().emit_use(false);
331 let tables = vec![TableSchema {
332 name: "products".to_string(),
333 columns: vec![ColumnSchema {
334 name: "price".to_string(),
335 rust_type: "Option<f64>".to_string(),
336 }],
337 }];
338 let output = gen.generate(&tables);
339
340 assert!(output.contains("price: Option<f64>,"));
341 }
342
343 #[test]
344 fn test_schema_generator_compound_type() {
345 let gen = SchemaGenerator::new().emit_use(false);
346 let tables = vec![TableSchema {
347 name: "files".to_string(),
348 columns: vec![ColumnSchema {
349 name: "content".to_string(),
350 rust_type: "Vec<u8>".to_string(),
351 }],
352 }];
353 let output = gen.generate(&tables);
354
355 assert!(output.contains("content: Vec<u8>,"));
356 }
357
358 #[test]
359 fn test_generated_code_is_valid_syntax() {
360 let gen = SchemaGenerator::new().emit_use(false);
362 let tables = vec![TableSchema {
363 name: "typed_validate_test".to_string(),
364 columns: vec![
365 ColumnSchema {
366 name: "id".to_string(),
367 rust_type: "i64".to_string(),
368 },
369 ColumnSchema {
370 name: "name".to_string(),
371 rust_type: "String".to_string(),
372 },
373 ],
374 }];
375 let output = gen.generate(&tables);
376
377 assert!(output.contains("typed_query! {"));
379 assert!(output.contains("table typed_validate_test {"));
380 assert!(output.contains("id: i64,"));
381 assert!(output.contains("name: String,"));
382 let count_open = output.matches("typed_query! {").count();
384 let count_close = output.matches("}\n}").count();
385 assert_eq!(count_open, 1);
386 assert_eq!(count_close, 1);
387 }
388}