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 emit_model_structs: bool,
65 model_derives: String,
70}
71
72impl Default for SchemaGenerator {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl SchemaGenerator {
79 pub fn new() -> Self {
81 Self {
82 header: format!(
83 "// Auto-generated by sz-orm-cli generate schema at {}\n\
84 // DO NOT EDIT MANUALLY — re-run the command to refresh.\n\
85 //\n\
86 // This file contains typed_query! table declarations\n\
87 // enabling compile-time column-name verification.\n",
88 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
89 ),
90 emit_use: true,
91 emit_model_structs: false,
92 model_derives: "Debug, Clone, serde::Serialize, serde::Deserialize".to_string(),
93 }
94 }
95
96 pub fn with_header(mut self, header: impl Into<String>) -> Self {
98 self.header = header.into();
99 self
100 }
101
102 pub fn emit_use(mut self, emit: bool) -> Self {
104 self.emit_use = emit;
105 self
106 }
107
108 pub fn emit_model_structs(mut self, emit: bool) -> Self {
125 self.emit_model_structs = emit;
126 self
127 }
128
129 pub fn with_model_derives(mut self, derives: impl Into<String>) -> Self {
134 self.model_derives = derives.into();
135 self
136 }
137
138 pub fn generate(&self, tables: &[TableSchema]) -> String {
140 let mut out = String::new();
141
142 let _ = writeln!(out, "{}", self.header);
144 let _ = writeln!(out);
145
146 if self.emit_use {
148 let _ = writeln!(out, "use sz_orm_core::typed_query;");
149 if self.emit_model_structs
152 && !self.model_derives.is_empty()
153 && self.model_derives.contains("serde::")
154 {
155 let _ = writeln!(out, "use serde::{{Serialize, Deserialize}};");
156 }
157 let _ = writeln!(out);
158 }
159
160 if self.emit_model_structs {
162 for (idx, table) in tables.iter().enumerate() {
163 if idx > 0 {
164 let _ = writeln!(out);
165 }
166 let _ = write!(out, "{}", self.render_model_struct(table));
167 }
168 if !tables.is_empty() {
170 let _ = writeln!(out);
171 let _ = writeln!(out);
172 }
173 }
174
175 for (idx, table) in tables.iter().enumerate() {
177 if idx > 0 {
178 let _ = writeln!(out);
179 }
180 let _ = write!(out, "{}", self.render_table(table));
181 }
182
183 out
184 }
185
186 fn render_table(&self, table: &TableSchema) -> String {
188 let mut out = String::new();
189 let _ = writeln!(out, "typed_query! {{");
190 let _ = writeln!(out, " table {} {{", table.name);
191 for col in &table.columns {
192 let _ = writeln!(out, " {}: {},", col.name, col.rust_type);
193 }
194 let _ = writeln!(out, " }}");
195 let _ = writeln!(out, "}}");
196 out
197 }
198
199 fn render_model_struct(&self, table: &TableSchema) -> String {
204 let struct_name = to_pascal_case(&table.name);
205 let mut out = String::new();
206 if !self.model_derives.is_empty() {
208 let _ = writeln!(out, "#[derive({})]", self.model_derives);
209 }
210 let _ = writeln!(out, "#[table_name = \"{}\"]", table.name);
212 let _ = writeln!(out, "pub struct {} {{", struct_name);
213 for col in &table.columns {
214 let _ = writeln!(out, " pub {}: {},", col.name, col.rust_type);
215 }
216 let _ = writeln!(out, "}}");
217 out
218 }
219}
220
221fn to_pascal_case(input: &str) -> String {
226 let mut result = String::with_capacity(input.len());
227 let mut next_upper = true;
228 for ch in input.chars() {
229 if ch == '_' || ch == '-' || ch == ' ' {
230 next_upper = true;
231 continue;
232 }
233 if next_upper {
234 result.push(ch.to_ascii_uppercase());
235 next_upper = false;
236 } else {
237 result.push(ch);
238 }
239 }
240 result
241}
242
243pub fn sql_type_to_rust(sql_type: &str, nullable: bool) -> String {
260 let lower = sql_type.to_lowercase();
262 let after_param_strip = lower.split('(').next().unwrap_or(&lower).trim();
264 let base_type = after_param_strip
267 .strip_suffix(" unsigned")
268 .or_else(|| after_param_strip.strip_suffix(" zerofill"))
269 .unwrap_or(after_param_strip)
270 .trim();
271
272 let rust = match base_type {
273 "tinyint" => "i8",
275 "smallint" | "int2" | "smallserial" => "i16",
276 "bigint" | "int8" | "bigserial" => "i64",
277 "int" | "integer" | "int4" | "mediumint" | "serial" => "i32",
278 "float8" | "double" | "double precision" => "f64",
280 "float4" | "real" | "float" => "f32",
281 "decimal" | "numeric" => "f64",
282 "bool" | "boolean" => "bool",
284 "blob" | "bytea" | "binary" | "varbinary" | "tinyblob" | "mediumblob" | "longblob" => {
286 "Vec<u8>"
287 }
288 "date" => "String",
290 "datetime" | "timestamp" | "timestamptz" => "String",
291 "time" | "timetz" => "String",
292 "interval" => "String",
293 "json" | "jsonb" => "String",
295 "uuid" => "String",
297 "char" | "varchar" | "text" | "tinytext" | "mediumtext" | "longtext" => "String",
299 "enum" | "set" => "String",
300 _ => "String",
302 };
303
304 if nullable {
305 format!("Option<{}>", rust)
306 } else {
307 rust.to_string()
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn test_sql_type_to_rust_int() {
317 assert_eq!(sql_type_to_rust("INT", false), "i32");
318 assert_eq!(sql_type_to_rust("BIGINT", false), "i64");
319 assert_eq!(sql_type_to_rust("SMALLINT", false), "i16");
320 assert_eq!(sql_type_to_rust("TINYINT", false), "i8");
321 }
322
323 #[test]
324 fn test_sql_type_to_rust_float() {
325 assert_eq!(sql_type_to_rust("FLOAT", false), "f32");
326 assert_eq!(sql_type_to_rust("DOUBLE", false), "f64");
327 assert_eq!(sql_type_to_rust("DECIMAL(10,2)", false), "f64");
328 }
329
330 #[test]
331 fn test_sql_type_to_rust_bool() {
332 assert_eq!(sql_type_to_rust("BOOLEAN", false), "bool");
333 assert_eq!(sql_type_to_rust("TINYINT(1)", false), "i8");
334 }
335
336 #[test]
337 fn test_sql_type_to_rust_string() {
338 assert_eq!(sql_type_to_rust("VARCHAR(255)", false), "String");
339 assert_eq!(sql_type_to_rust("TEXT", false), "String");
340 assert_eq!(sql_type_to_rust("CHAR(36)", false), "String");
341 }
342
343 #[test]
344 fn test_sql_type_to_rust_binary() {
345 assert_eq!(sql_type_to_rust("BLOB", false), "Vec<u8>");
346 assert_eq!(sql_type_to_rust("BYTEA", false), "Vec<u8>");
347 }
348
349 #[test]
350 fn test_sql_type_to_rust_nullable() {
351 assert_eq!(sql_type_to_rust("INT", true), "Option<i32>");
352 assert_eq!(sql_type_to_rust("VARCHAR(255)", true), "Option<String>");
353 }
354
355 #[test]
356 fn test_sql_type_to_rust_pg_types() {
357 assert_eq!(sql_type_to_rust("int8", false), "i64");
358 assert_eq!(sql_type_to_rust("int2", false), "i16");
359 assert_eq!(sql_type_to_rust("float8", false), "f64");
360 assert_eq!(sql_type_to_rust("float4", false), "f32");
361 assert_eq!(sql_type_to_rust("bytea", false), "Vec<u8>");
362 }
363
364 #[test]
365 fn test_sql_type_to_rust_json_uuid() {
366 assert_eq!(sql_type_to_rust("JSON", false), "String");
367 assert_eq!(sql_type_to_rust("JSONB", false), "String");
368 assert_eq!(sql_type_to_rust("UUID", false), "String");
369 }
370
371 #[test]
372 fn test_schema_generator_single_table() {
373 let gen = SchemaGenerator::new().emit_use(false);
374 let tables = vec![TableSchema {
375 name: "users".to_string(),
376 columns: vec![
377 ColumnSchema {
378 name: "id".to_string(),
379 rust_type: "i64".to_string(),
380 },
381 ColumnSchema {
382 name: "name".to_string(),
383 rust_type: "String".to_string(),
384 },
385 ],
386 }];
387 let output = gen.generate(&tables);
388
389 assert!(output.contains("table users {"));
390 assert!(output.contains("id: i64,"));
391 assert!(output.contains("name: String,"));
392 assert!(output.contains("typed_query! {"));
393 }
394
395 #[test]
396 fn test_schema_generator_multiple_tables() {
397 let gen = SchemaGenerator::new().emit_use(false);
398 let tables = vec![
399 TableSchema {
400 name: "users".to_string(),
401 columns: vec![ColumnSchema {
402 name: "id".to_string(),
403 rust_type: "i64".to_string(),
404 }],
405 },
406 TableSchema {
407 name: "orders".to_string(),
408 columns: vec![ColumnSchema {
409 name: "order_id".to_string(),
410 rust_type: "i64".to_string(),
411 }],
412 },
413 ];
414 let output = gen.generate(&tables);
415
416 assert!(output.contains("table users {"));
417 assert!(output.contains("table orders {"));
418 let users_end = output.find("}").unwrap();
420 let orders_start = output.find("table orders").unwrap();
421 let between = &output[users_end..orders_start];
422 assert!(between.contains("\n\n"));
423 }
424
425 #[test]
426 fn test_schema_generator_with_use_statement() {
427 let gen = SchemaGenerator::new().emit_use(true);
428 let tables = vec![TableSchema {
429 name: "t".to_string(),
430 columns: vec![],
431 }];
432 let output = gen.generate(&tables);
433
434 assert!(output.contains("use sz_orm_core::typed_query;"));
435 }
436
437 #[test]
438 fn test_schema_generator_header() {
439 let gen = SchemaGenerator::new();
440 let output = gen.generate(&[]);
441 assert!(output.contains("Auto-generated"));
442 assert!(output.contains("DO NOT EDIT MANUALLY"));
443 }
444
445 #[test]
446 fn test_schema_generator_custom_header() {
447 let gen = SchemaGenerator::new().with_header("// Custom header\n");
448 let output = gen.generate(&[]);
449 assert!(output.starts_with("// Custom header"));
450 assert!(!output.contains("Auto-generated"));
451 }
452
453 #[test]
454 fn test_schema_generator_empty_tables() {
455 let gen = SchemaGenerator::new().emit_use(false);
456 let output = gen.generate(&[]);
457 assert!(output.contains("Auto-generated"));
459 assert!(!output.contains("typed_query! {"));
462 }
463
464 #[test]
465 fn test_schema_generator_option_type() {
466 let gen = SchemaGenerator::new().emit_use(false);
467 let tables = vec![TableSchema {
468 name: "products".to_string(),
469 columns: vec![ColumnSchema {
470 name: "price".to_string(),
471 rust_type: "Option<f64>".to_string(),
472 }],
473 }];
474 let output = gen.generate(&tables);
475
476 assert!(output.contains("price: Option<f64>,"));
477 }
478
479 #[test]
480 fn test_schema_generator_compound_type() {
481 let gen = SchemaGenerator::new().emit_use(false);
482 let tables = vec![TableSchema {
483 name: "files".to_string(),
484 columns: vec![ColumnSchema {
485 name: "content".to_string(),
486 rust_type: "Vec<u8>".to_string(),
487 }],
488 }];
489 let output = gen.generate(&tables);
490
491 assert!(output.contains("content: Vec<u8>,"));
492 }
493
494 #[test]
495 fn test_generated_code_is_valid_syntax() {
496 let gen = SchemaGenerator::new().emit_use(false);
498 let tables = vec![TableSchema {
499 name: "typed_validate_test".to_string(),
500 columns: vec![
501 ColumnSchema {
502 name: "id".to_string(),
503 rust_type: "i64".to_string(),
504 },
505 ColumnSchema {
506 name: "name".to_string(),
507 rust_type: "String".to_string(),
508 },
509 ],
510 }];
511 let output = gen.generate(&tables);
512
513 assert!(output.contains("typed_query! {"));
515 assert!(output.contains("table typed_validate_test {"));
516 assert!(output.contains("id: i64,"));
517 assert!(output.contains("name: String,"));
518 let count_open = output.matches("typed_query! {").count();
520 let count_close = output.matches("}\n}").count();
521 assert_eq!(count_open, 1);
522 assert_eq!(count_close, 1);
523 }
524
525 #[test]
528 fn test_sql_type_to_rust_interval_not_int() {
529 assert_eq!(sql_type_to_rust("INTERVAL", false), "String");
531 assert_eq!(sql_type_to_rust("interval", false), "String");
532 assert_eq!(sql_type_to_rust("INTERVAL DAY TO SECOND", false), "String");
534 }
535
536 #[test]
537 fn test_sql_type_to_rust_mediumint() {
538 assert_eq!(sql_type_to_rust("MEDIUMINT", false), "i32");
540 assert_eq!(sql_type_to_rust("MEDIUMINT(8)", false), "i32");
541 }
542
543 #[test]
544 fn test_sql_type_to_rust_integer_alias() {
545 assert_eq!(sql_type_to_rust("INTEGER", false), "i32");
547 assert_eq!(sql_type_to_rust("INTEGER(11)", false), "i32");
548 }
549
550 #[test]
551 fn test_sql_type_to_rust_serial_types() {
552 assert_eq!(sql_type_to_rust("SERIAL", false), "i32");
554 assert_eq!(sql_type_to_rust("BIGSERIAL", false), "i64");
555 assert_eq!(sql_type_to_rust("SMALLSERIAL", false), "i16");
556 }
557
558 #[test]
559 fn test_sql_type_to_rust_unsigned_suffix() {
560 assert_eq!(sql_type_to_rust("INT UNSIGNED", false), "i32");
562 assert_eq!(sql_type_to_rust("BIGINT UNSIGNED", false), "i64");
563 assert_eq!(sql_type_to_rust("TINYINT UNSIGNED", false), "i8");
564 assert_eq!(sql_type_to_rust("SMALLINT UNSIGNED", false), "i16");
565 assert_eq!(sql_type_to_rust("MEDIUMINT UNSIGNED", false), "i32");
566 }
567
568 #[test]
569 fn test_sql_type_to_rust_zerofill_suffix() {
570 assert_eq!(sql_type_to_rust("INT ZEROFILL", false), "i32");
572 assert_eq!(sql_type_to_rust("INT(4) ZEROFILL", false), "i32");
573 }
574
575 #[test]
576 fn test_sql_type_to_rust_timestamptz() {
577 assert_eq!(sql_type_to_rust("TIMESTAMPTZ", false), "String");
579 assert_eq!(sql_type_to_rust("timestamptz", false), "String");
580 }
581
582 #[test]
583 fn test_sql_type_to_rust_timetz() {
584 assert_eq!(sql_type_to_rust("TIMETZ", false), "String");
586 assert_eq!(sql_type_to_rust("timetz", false), "String");
587 }
588
589 #[test]
590 fn test_sql_type_to_rust_double_precision() {
591 assert_eq!(sql_type_to_rust("DOUBLE PRECISION", false), "f64");
593 assert_eq!(sql_type_to_rust("double precision", false), "f64");
594 }
595
596 #[test]
597 fn test_sql_type_to_rust_varbinary() {
598 assert_eq!(sql_type_to_rust("VARBINARY(255)", false), "Vec<u8>");
600 assert_eq!(sql_type_to_rust("VARBINARY", false), "Vec<u8>");
601 }
602
603 #[test]
604 fn test_sql_type_to_rust_blob_variants() {
605 assert_eq!(sql_type_to_rust("TINYBLOB", false), "Vec<u8>");
607 assert_eq!(sql_type_to_rust("MEDIUMBLOB", false), "Vec<u8>");
608 assert_eq!(sql_type_to_rust("LONGBLOB", false), "Vec<u8>");
609 }
610
611 #[test]
612 fn test_sql_type_to_rust_text_variants() {
613 assert_eq!(sql_type_to_rust("TINYTEXT", false), "String");
615 assert_eq!(sql_type_to_rust("MEDIUMTEXT", false), "String");
616 assert_eq!(sql_type_to_rust("LONGTEXT", false), "String");
617 }
618
619 #[test]
620 fn test_sql_type_to_rust_enum_and_set() {
621 assert_eq!(sql_type_to_rust("ENUM('a','b')", false), "String");
623 assert_eq!(sql_type_to_rust("SET('a','b')", false), "String");
624 }
625
626 #[test]
627 fn test_sql_type_to_rust_decimal_with_space() {
628 assert_eq!(sql_type_to_rust("DECIMAL(10, 2)", false), "f64");
630 assert_eq!(sql_type_to_rust("NUMERIC(8, 2)", false), "f64");
631 }
632
633 #[test]
634 fn test_sql_type_to_rust_unknown_defaults_string() {
635 assert_eq!(sql_type_to_rust("UNKNOWN_TYPE", false), "String");
637 assert_eq!(sql_type_to_rust("citext", false), "String");
638 assert_eq!(sql_type_to_rust("money", false), "String");
639 }
640
641 #[test]
642 fn test_sql_type_to_rust_interval_nullable() {
643 assert_eq!(sql_type_to_rust("INTERVAL", true), "Option<String>");
645 }
646
647 #[test]
652 fn test_to_pascal_case_basic() {
653 assert_eq!(to_pascal_case("users"), "Users");
654 assert_eq!(to_pascal_case("orders"), "Orders");
655 }
656
657 #[test]
658 fn test_to_pascal_case_snake_case() {
659 assert_eq!(to_pascal_case("order_items"), "OrderItems");
660 assert_eq!(to_pascal_case("user_profiles"), "UserProfiles");
661 }
662
663 #[test]
664 fn test_to_pascal_case_kebab_case() {
665 assert_eq!(to_pascal_case("user-profile"), "UserProfile");
666 assert_eq!(to_pascal_case("order-item"), "OrderItem");
667 }
668
669 #[test]
670 fn test_to_pascal_case_with_spaces() {
671 assert_eq!(to_pascal_case("user profile"), "UserProfile");
672 }
673
674 #[test]
675 fn test_to_pascal_case_single_char() {
676 assert_eq!(to_pascal_case("a"), "A");
677 assert_eq!(to_pascal_case("_a"), "A");
678 }
679
680 #[test]
681 fn test_to_pascal_case_already_pascal() {
682 assert_eq!(to_pascal_case("Users"), "Users");
684 }
685
686 #[test]
687 fn test_emit_model_structs_generates_struct() {
688 let gen = SchemaGenerator::new()
689 .emit_use(false)
690 .emit_model_structs(true);
691 let tables = vec![TableSchema {
692 name: "users".to_string(),
693 columns: vec![
694 ColumnSchema {
695 name: "id".to_string(),
696 rust_type: "i64".to_string(),
697 },
698 ColumnSchema {
699 name: "name".to_string(),
700 rust_type: "String".to_string(),
701 },
702 ],
703 }];
704 let output = gen.generate(&tables);
705
706 assert!(output.contains("pub struct Users {"));
708 assert!(output.contains("pub id: i64,"));
709 assert!(output.contains("pub name: String,"));
710 assert!(output.contains("#[derive("));
712 assert!(output.contains("Debug"));
713 assert!(output.contains("Clone"));
714 assert!(output.contains("#[table_name = \"users\"]"));
716 assert!(output.contains("typed_query! {"));
718 assert!(output.contains("table users {"));
719 }
720
721 #[test]
722 fn test_emit_model_structs_disabled_by_default() {
723 let gen = SchemaGenerator::new().emit_use(false);
724 let tables = vec![TableSchema {
725 name: "users".to_string(),
726 columns: vec![ColumnSchema {
727 name: "id".to_string(),
728 rust_type: "i64".to_string(),
729 }],
730 }];
731 let output = gen.generate(&tables);
732
733 assert!(!output.contains("pub struct Users"));
735 assert!(output.contains("typed_query! {"));
737 }
738
739 #[test]
740 fn test_emit_model_structs_multiple_tables() {
741 let gen = SchemaGenerator::new()
742 .emit_use(false)
743 .emit_model_structs(true);
744 let tables = vec![
745 TableSchema {
746 name: "users".to_string(),
747 columns: vec![ColumnSchema {
748 name: "id".to_string(),
749 rust_type: "i64".to_string(),
750 }],
751 },
752 TableSchema {
753 name: "order_items".to_string(),
754 columns: vec![ColumnSchema {
755 name: "item_id".to_string(),
756 rust_type: "i64".to_string(),
757 }],
758 },
759 ];
760 let output = gen.generate(&tables);
761
762 assert!(output.contains("pub struct Users {"));
764 assert!(output.contains("pub struct OrderItems {"));
765 }
766
767 #[test]
768 fn test_emit_model_structs_with_custom_derives() {
769 let gen = SchemaGenerator::new()
770 .emit_use(false)
771 .emit_model_structs(true)
772 .with_model_derives("Debug, Clone");
773 let tables = vec![TableSchema {
774 name: "users".to_string(),
775 columns: vec![ColumnSchema {
776 name: "id".to_string(),
777 rust_type: "i64".to_string(),
778 }],
779 }];
780 let output = gen.generate(&tables);
781
782 assert!(output.contains("#[derive(Debug, Clone)]"));
784 assert!(!output.contains("serde::Serialize"));
786 }
787
788 #[test]
789 fn test_emit_model_structs_with_empty_derives() {
790 let gen = SchemaGenerator::new()
791 .emit_use(false)
792 .emit_model_structs(true)
793 .with_model_derives("");
794 let tables = vec![TableSchema {
795 name: "users".to_string(),
796 columns: vec![ColumnSchema {
797 name: "id".to_string(),
798 rust_type: "i64".to_string(),
799 }],
800 }];
801 let output = gen.generate(&tables);
802
803 assert!(!output.contains("#[derive("));
805 assert!(output.contains("pub struct Users {"));
807 }
808
809 #[test]
810 fn test_emit_model_structs_with_nullable_fields() {
811 let gen = SchemaGenerator::new()
812 .emit_use(false)
813 .emit_model_structs(true);
814 let tables = vec![TableSchema {
815 name: "products".to_string(),
816 columns: vec![
817 ColumnSchema {
818 name: "id".to_string(),
819 rust_type: "i64".to_string(),
820 },
821 ColumnSchema {
822 name: "price".to_string(),
823 rust_type: "Option<f64>".to_string(),
824 },
825 ColumnSchema {
826 name: "description".to_string(),
827 rust_type: "Option<String>".to_string(),
828 },
829 ],
830 }];
831 let output = gen.generate(&tables);
832
833 assert!(output.contains("pub struct Products {"));
834 assert!(output.contains("pub id: i64,"));
835 assert!(output.contains("pub price: Option<f64>,"));
836 assert!(output.contains("pub description: Option<String>,"));
837 }
838
839 #[test]
840 fn test_emit_model_structs_with_serde_use() {
841 let gen = SchemaGenerator::new()
842 .emit_use(true)
843 .emit_model_structs(true);
844 let tables = vec![TableSchema {
845 name: "users".to_string(),
846 columns: vec![ColumnSchema {
847 name: "id".to_string(),
848 rust_type: "i64".to_string(),
849 }],
850 }];
851 let output = gen.generate(&tables);
852
853 assert!(output.contains("use serde::{Serialize, Deserialize};"));
855 }
856
857 #[test]
858 fn test_emit_model_structs_empty_tables() {
859 let gen = SchemaGenerator::new()
860 .emit_use(false)
861 .emit_model_structs(true);
862 let output = gen.generate(&[]);
863
864 assert!(!output.contains("pub struct"));
866 assert!(output.contains("Auto-generated"));
868 }
869}