1pub mod backend_options;
2pub mod backend_trait;
3pub mod backends;
4pub mod overrides;
5pub mod provenance;
6pub mod resolve;
7pub mod validation;
8
9pub use backend_trait::{
10 CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
11};
12pub use backends::get_backend;
13pub use overrides::TypeOverride;
14
15use scythe_backend::manifest::BackendManifest;
16use scythe_backend::naming::{row_struct_name, to_pascal_case};
17
18use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, EnumInfo, NestedStructInfo};
19use scythe_core::catalog::Catalog;
20use scythe_core::errors::{ErrorCode, ScytheError};
21use scythe_core::parser::QueryCommand;
22
23#[derive(Debug, Clone, Default)]
24#[non_exhaustive]
25pub struct GeneratedCode {
26 pub query_fn: Option<String>,
27 pub row_struct: Option<String>,
28 pub model_struct: Option<String>,
29 pub enum_def: Option<String>,
30 pub nested_struct_defs: Vec<NestedStructDef>,
41}
42
43impl GeneratedCode {
44 #[must_use]
64 pub fn build(init: impl FnOnce(&mut Self)) -> Self {
65 let mut code = Self::default();
66 init(&mut code);
67 code
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct NestedStructDef {
75 pub name: String,
78 pub code: String,
80}
81
82pub fn singularize(name: &str) -> String {
84 if let Some(stem) = name.strip_suffix("ies") {
85 format!("{stem}y")
86 } else if name.ends_with("sses")
87 || name.ends_with("shes")
88 || name.ends_with("ches")
89 || name.ends_with("xes")
90 || name.ends_with("zes")
91 || name.ends_with("ses")
92 {
93 name[..name.len() - 2].to_string()
94 } else if name.ends_with('s') && !name.ends_with("ss") {
95 name[..name.len() - 1].to_string()
96 } else {
97 name.to_string()
98 }
99}
100
101pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
103 let backend = get_backend(backend_name, "postgresql")?;
104 Ok(backend.manifest().clone())
105}
106
107fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
109 if let Some(ref table_name) = analyzed.source_table {
110 let singular = singularize(table_name);
111 to_pascal_case(&singular).into_owned()
112 } else {
113 row_struct_name(&analyzed.name, &manifest.naming)
114 }
115}
116
117pub fn generate_with_backend(
119 analyzed: &AnalyzedQuery,
120 backend: &dyn CodegenBackend,
121) -> Result<GeneratedCode, ScytheError> {
122 generate_with_backend_and_overrides(analyzed, backend, &[])
123}
124
125pub fn generate_with_backend_and_overrides(
127 analyzed: &AnalyzedQuery,
128 backend: &dyn CodegenBackend,
129 overrides: &[TypeOverride],
130) -> Result<GeneratedCode, ScytheError> {
131 let manifest = backend.manifest();
132 let source_table = analyzed.source_table.as_deref().unwrap_or("");
133
134 let (degraded_columns, nested_struct_defs) = if analyzed.nested_structs.is_empty() {
146 (None, Vec::new())
147 } else {
148 let (cols, defs) = degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
149 (Some(cols), defs)
150 };
151 let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
152
153 let columns = resolve::resolve_columns(
154 degraded_columns.as_deref().unwrap_or(&analyzed.columns),
155 manifest,
156 overrides,
157 source_table,
158 )?;
159 let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
160
161 let mut result = GeneratedCode::default();
162
163 let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs)?;
164 if !enum_def.is_empty() {
165 result.enum_def = Some(enum_def);
166 }
167
168 let needs_row_struct = matches!(
169 analyzed.command,
170 QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
171 );
172 if needs_row_struct && !analyzed.columns.is_empty() {
173 if let Some(ref table_name) = analyzed.source_table {
174 result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
175 } else {
176 result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
177 }
178 }
179
180 let mut extra_defs = String::new();
181 for comp in &analyzed.composites {
182 let from_nested = nested_refs.composites.contains(comp.sql_name.as_str());
189 if !from_nested && !type_referenced_by_columns(analyzed, "composite::", &comp.sql_name) {
190 continue;
191 }
192 if !extra_defs.is_empty() {
193 extra_defs.push_str("\n\n");
194 }
195 if from_nested {
196 extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
197 } else {
198 extra_defs.push_str(&backend.generate_composite_def(comp)?);
199 }
200 }
201 if !extra_defs.is_empty() {
202 if let Some(ref mut existing) = result.model_struct {
203 existing.push_str("\n\n");
204 existing.push_str(&extra_defs);
205 } else {
206 result.model_struct = Some(extra_defs);
207 }
208 }
209 result.nested_struct_defs = nested_struct_defs;
210
211 let struct_name = determine_struct_name(analyzed, manifest);
212
213 if analyzed.command == QueryCommand::Grouped {
214 let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
215 ScytheError::new(
216 ErrorCode::InternalError,
217 format!(
218 "query '{}' is :grouped but is missing @group_by annotation",
219 analyzed.name
220 ),
221 )
222 })?;
223
224 let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
225 let child_struct_name = {
226 let suffix = &manifest.naming.row_suffix;
227 let base = parent_struct_name.trim_end_matches(suffix.as_str());
228 format!("{}Child{}", base, suffix)
229 };
230
231 let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
237 (None, None)
238 } else {
239 let (dp, _) =
240 degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
241 let (dc, _) =
242 degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
243 (Some(dp), Some(dc))
244 };
245 let parent_cols = resolve::resolve_columns(
246 degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
247 manifest,
248 overrides,
249 source_table,
250 )?;
251 let child_cols = resolve::resolve_columns(
252 degraded_child.as_deref().unwrap_or(&group_by.child_columns),
253 manifest,
254 overrides,
255 source_table,
256 )?;
257
258 result.row_struct = Some(backend.generate_grouped_structs(
259 &parent_struct_name,
260 &child_struct_name,
261 &parent_cols,
262 &child_cols,
263 &group_by.key_column,
264 )?);
265 result.query_fn = Some(
266 backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
267 analyzed,
268 parent_struct_name: &parent_struct_name,
269 child_struct_name: &child_struct_name,
270 all_columns: &columns,
271 parent_columns: &parent_cols,
272 child_columns: &child_cols,
273 params: ¶ms,
274 key_column: &group_by.key_column,
275 })?,
276 );
277 } else {
278 result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
279 }
280
281 Ok(result)
282}
283
284#[derive(Debug, Default)]
300struct NestedTypeRefs {
301 enums: ahash::AHashSet<String>,
302 composites: ahash::AHashSet<String>,
303}
304
305impl NestedTypeRefs {
306 fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
307 let mut refs = Self::default();
308 for nested in &analyzed.nested_structs {
309 if !supported.iter().any(|def| def.name == nested.name) {
310 continue;
311 }
312 for field in &nested.fields {
313 if let Some(name) = field.neutral_type.strip_prefix("enum::") {
314 refs.enums.insert(name.to_string());
315 } else if let Some(name) = field.neutral_type.strip_prefix("composite::") {
316 refs.composites.insert(name.to_string());
317 }
318 }
319 }
320 refs
321 }
322}
323
324fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
331 let neutral = format!("{prefix}{sql_name}");
332 analyzed.columns.iter().any(|col| col.neutral_type == neutral)
333 || analyzed.params.iter().any(|param| param.neutral_type == neutral)
334}
335
336pub fn nested_type_is_emitted(
343 analyzed: &AnalyzedQuery,
344 supported: &[NestedStructDef],
345 prefix: &str,
346 sql_name: &str,
347) -> bool {
348 let neutral = format!("{prefix}{sql_name}");
349 analyzed
350 .nested_structs
351 .iter()
352 .filter(|nested| supported.iter().any(|def| def.name == nested.name))
353 .any(|nested| nested.fields.iter().any(|field| field.neutral_type == neutral))
354}
355
356fn generate_enum_defs_via_backend(
358 analyzed: &AnalyzedQuery,
359 backend: &dyn CodegenBackend,
360 nested_refs: &NestedTypeRefs,
361) -> Result<String, ScytheError> {
362 use ahash::AHashSet;
363 use std::fmt::Write;
364
365 let mut out = String::new();
366 let mut seen_enums: AHashSet<String> = AHashSet::new();
367
368 let enum_sources: Vec<&str> = analyzed
369 .columns
370 .iter()
371 .filter_map(|col| col.neutral_type.strip_prefix("enum::"))
372 .chain(
373 analyzed
374 .params
375 .iter()
376 .filter_map(|p| p.neutral_type.strip_prefix("enum::")),
377 )
378 .chain(nested_refs.enums.iter().map(String::as_str))
379 .collect();
380
381 for sql_name in enum_sources {
382 if !seen_enums.insert(sql_name.to_string()) {
383 continue;
384 }
385
386 if !out.is_empty() {
387 let _ = writeln!(out);
388 }
389
390 let stub_info;
391 let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
392 Some(info) => info,
393 None => {
394 stub_info = EnumInfo {
395 sql_name: sql_name.to_string(),
396 values: vec![],
397 };
398 &stub_info
399 }
400 };
401 if nested_refs.enums.contains(sql_name) {
402 out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
403 } else {
404 out.push_str(&backend.generate_enum_def(enum_info)?);
405 }
406 }
407
408 Ok(out)
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub(crate) struct NestedColumnShape<'a> {
414 pub(crate) is_array: bool,
416 pub(crate) element_nullable: bool,
420 pub(crate) name: &'a str,
422}
423
424pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
443 let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
444 let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
445 return Some(NestedColumnShape {
446 is_array: false,
447 element_nullable: false,
448 name: inner,
449 });
450 };
451 match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
452 Some(name) => Some(NestedColumnShape {
453 is_array: true,
454 element_nullable: true,
455 name,
456 }),
457 None => Some(NestedColumnShape {
458 is_array: true,
459 element_nullable: false,
460 name: element,
461 }),
462 }
463}
464
465fn nested_struct_pascal_name(neutral_type: &str) -> Option<&str> {
468 nested_struct_shape(neutral_type).map(|shape| shape.name)
469}
470
471pub fn degrade_unsupported_nested_structs(
498 columns: &[AnalyzedColumn],
499 nested_structs: &[NestedStructInfo],
500 backend: &dyn CodegenBackend,
501) -> Result<(Vec<AnalyzedColumn>, Vec<NestedStructDef>), ScytheError> {
502 if nested_structs.is_empty() {
503 return Ok((columns.to_vec(), Vec::new()));
504 }
505
506 use ahash::AHashSet;
507
508 let mut unsupported: AHashSet<String> = AHashSet::new();
509 let mut defs: Vec<NestedStructDef> = Vec::new();
510 for nested in nested_structs {
511 match backend.generate_nested_struct_def(nested)? {
512 Some(def) => defs.push(NestedStructDef {
513 name: nested.name.clone(),
514 code: def,
515 }),
516 None => {
517 unsupported.insert(to_pascal_case(&nested.name).into_owned());
518 }
519 }
520 }
521
522 if unsupported.is_empty() {
523 return Ok((columns.to_vec(), defs));
524 }
525
526 let degraded = columns
527 .iter()
528 .cloned()
529 .map(|mut col| {
530 if let Some(name) = nested_struct_pascal_name(&col.neutral_type)
531 && unsupported.contains(name)
532 {
533 col.neutral_type = "json".to_string();
534 }
535 col
536 })
537 .collect();
538
539 Ok((degraded, defs))
540}
541
542pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
544 let backend = get_backend("rust-sqlx", "postgresql")?;
545 generate_with_backend(analyzed, &*backend)
546}
547
548pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
550 Ok(GeneratedCode::default())
551}
552
553pub fn generate_single_enum_def_with_backend(
562 enum_info: &EnumInfo,
563 backend: &dyn CodegenBackend,
564 reachable_from_nested: bool,
565) -> Result<String, ScytheError> {
566 if reachable_from_nested {
567 backend.generate_enum_def_for_nested(enum_info)
568 } else {
569 backend.generate_enum_def(enum_info)
570 }
571}
572
573pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
576 use scythe_backend::naming::{enum_type_name, enum_variant_name};
577 use std::fmt::Write;
578
579 let mut out = String::with_capacity(256);
580 let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
581
582 let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
583 let _ = writeln!(
584 out,
585 "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
586 enum_info.sql_name
587 );
588 let _ = writeln!(out, "pub enum {type_name} {{");
589
590 for value in &enum_info.values {
591 let variant = enum_variant_name(value, &manifest.naming);
592 let _ = writeln!(out, " {variant},");
593 }
594
595 let _ = write!(out, "}}");
596 out
597}
598
599pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
601 let b = backends::sqlx::SqlxBackend::new("postgresql")?;
602 Ok(b.manifest().clone())
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
609 use scythe_core::parser::QueryCommand;
610
611 fn make_query(
612 name: &str,
613 command: QueryCommand,
614 sql: &str,
615 columns: Vec<AnalyzedColumn>,
616 params: Vec<AnalyzedParam>,
617 ) -> AnalyzedQuery {
618 AnalyzedQuery::build(|aq| {
619 aq.name = name.to_string();
620 aq.command = command;
621 aq.sql = sql.to_string();
622 aq.columns = columns;
623 aq.params = params;
624 aq.deprecated = None;
625 aq.source_table = None;
626 aq.composites = Vec::new();
627 aq.enums = Vec::new();
628 aq.optional_params = Vec::new();
629 aq.group_by = None;
630 aq.custom = Vec::new();
631 })
632 }
633
634 #[test]
635 fn test_generate_select_many() {
636 let query = make_query(
637 "ListUsers",
638 QueryCommand::Many,
639 "SELECT id, name, email FROM users",
640 vec![
641 AnalyzedColumn {
642 name: "id".to_string(),
643 neutral_type: "int32".to_string(),
644 nullable: false,
645 ..Default::default()
646 },
647 AnalyzedColumn {
648 name: "name".to_string(),
649 neutral_type: "string".to_string(),
650 nullable: false,
651 ..Default::default()
652 },
653 AnalyzedColumn {
654 name: "email".to_string(),
655 neutral_type: "string".to_string(),
656 nullable: true,
657 ..Default::default()
658 },
659 ],
660 vec![],
661 );
662
663 let result = generate(&query).unwrap();
664
665 let row_struct = result.row_struct.unwrap();
666 assert!(row_struct.contains("pub struct ListUsersRow"));
667 assert!(row_struct.contains("pub id: i32"));
668 assert!(row_struct.contains("pub name: String"));
669 assert!(row_struct.contains("pub email: Option<String>"));
670
671 let query_fn = result.query_fn.unwrap();
672 assert!(query_fn.contains("pub async fn list_users("));
673 assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
674 assert!(query_fn.contains(".fetch_all(pool)"));
675 }
676
677 #[test]
678 fn test_generate_select_one_with_param() {
679 let query = make_query(
680 "GetUser",
681 QueryCommand::One,
682 "SELECT id, name FROM users WHERE id = $1",
683 vec![
684 AnalyzedColumn {
685 name: "id".to_string(),
686 neutral_type: "int32".to_string(),
687 nullable: false,
688 ..Default::default()
689 },
690 AnalyzedColumn {
691 name: "name".to_string(),
692 neutral_type: "string".to_string(),
693 nullable: false,
694 ..Default::default()
695 },
696 ],
697 vec![AnalyzedParam {
698 name: "id".to_string(),
699 neutral_type: "int32".to_string(),
700 nullable: false,
701 position: 1,
702 }],
703 );
704
705 let result = generate(&query).unwrap();
706
707 let query_fn = result.query_fn.unwrap();
708 assert!(query_fn.contains("pub async fn get_user("));
709 assert!(query_fn.contains("id: i32"));
710 assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
711 assert!(query_fn.contains(".fetch_one(pool)"));
712 }
713
714 #[test]
715 fn test_generate_exec() {
716 let query = make_query(
717 "DeleteUser",
718 QueryCommand::Exec,
719 "DELETE FROM users WHERE id = $1",
720 vec![],
721 vec![AnalyzedParam {
722 name: "id".to_string(),
723 neutral_type: "int32".to_string(),
724 nullable: false,
725 position: 1,
726 }],
727 );
728
729 let result = generate(&query).unwrap();
730
731 assert!(result.row_struct.is_none());
732
733 let query_fn = result.query_fn.unwrap();
734 assert!(query_fn.contains("pub async fn delete_user("));
735 assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
736 assert!(query_fn.contains(".execute(pool)"));
737 }
738
739 #[test]
740 fn test_generate_with_enum_column() {
741 let query = make_query(
742 "GetUserStatus",
743 QueryCommand::One,
744 "SELECT id, status FROM users WHERE id = $1",
745 vec![
746 AnalyzedColumn {
747 name: "id".to_string(),
748 neutral_type: "int32".to_string(),
749 nullable: false,
750 ..Default::default()
751 },
752 AnalyzedColumn {
753 name: "status".to_string(),
754 neutral_type: "enum::user_status".to_string(),
755 nullable: false,
756 ..Default::default()
757 },
758 ],
759 vec![AnalyzedParam {
760 name: "id".to_string(),
761 neutral_type: "int32".to_string(),
762 nullable: false,
763 position: 1,
764 }],
765 );
766
767 let result = generate(&query).unwrap();
768
769 assert!(result.enum_def.is_some());
770 let enum_def = result.enum_def.unwrap();
771 assert!(enum_def.contains("pub enum UserStatus"));
772 assert!(enum_def.contains("type_name = \"user_status\""));
773
774 let row_struct = result.row_struct.unwrap();
775 assert!(row_struct.contains("pub status: UserStatus"));
776 }
777
778 #[test]
779 fn test_generate_from_catalog_returns_default() {
780 let catalog = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER);"]).unwrap();
781 let result = generate_from_catalog(&catalog).unwrap();
782 assert!(result.query_fn.is_none());
783 assert!(result.row_struct.is_none());
784 }
785
786 #[test]
787 fn test_singularize_basic() {
788 assert_eq!(singularize("users"), "user");
789 assert_eq!(singularize("orders"), "order");
790 assert_eq!(singularize("posts"), "post");
791 }
792
793 #[test]
794 fn test_singularize_ies() {
795 assert_eq!(singularize("categories"), "category");
796 assert_eq!(singularize("entries"), "entry");
797 }
798
799 #[test]
800 fn test_singularize_sses() {
801 assert_eq!(singularize("addresses"), "address");
802 assert_eq!(singularize("classes"), "class");
803 }
804
805 #[test]
806 fn test_singularize_no_change() {
807 assert_eq!(singularize("status"), "statu");
808 assert_eq!(singularize("boss"), "boss");
809 assert_eq!(singularize("address"), "address");
810 }
811
812 #[test]
813 fn test_singularize_shes_ches_xes() {
814 assert_eq!(singularize("batches"), "batch");
815 assert_eq!(singularize("boxes"), "box");
816 assert_eq!(singularize("wishes"), "wish");
817 }
818
819 fn make_grouped_query() -> AnalyzedQuery {
820 let parent_cols = vec![
821 AnalyzedColumn {
822 name: "id".to_string(),
823 neutral_type: "int32".to_string(),
824 nullable: false,
825 ..Default::default()
826 },
827 AnalyzedColumn {
828 name: "name".to_string(),
829 neutral_type: "string".to_string(),
830 nullable: false,
831 ..Default::default()
832 },
833 AnalyzedColumn {
834 name: "email".to_string(),
835 neutral_type: "string".to_string(),
836 nullable: false,
837 ..Default::default()
838 },
839 ];
840 let child_cols = vec![
841 AnalyzedColumn {
842 name: "order_id".to_string(),
843 neutral_type: "int32".to_string(),
844 nullable: false,
845 ..Default::default()
846 },
847 AnalyzedColumn {
848 name: "total".to_string(),
849 neutral_type: "decimal".to_string(),
850 nullable: true,
851 ..Default::default()
852 },
853 AnalyzedColumn {
854 name: "order_date".to_string(),
855 neutral_type: "datetime".to_string(),
856 nullable: false,
857 ..Default::default()
858 },
859 ];
860 let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
861
862 AnalyzedQuery::build(|aq| {
863 aq.name = "GetUsersWithOrders".to_string();
864 aq.command = QueryCommand::Grouped;
865 aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
866 SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
867 FROM users u\n\
868 JOIN orders o ON o.user_id = u.id"
869 .to_string();
870 aq.columns = all_cols;
871 aq.params = vec![];
872 aq.deprecated = None;
873 aq.source_table = None;
874 aq.composites = vec![];
875 aq.enums = vec![];
876 aq.optional_params = vec![];
877 aq.group_by = Some(GroupByConfig {
878 table: "users".to_string(),
879 key_column: "id".to_string(),
880 parent_columns: parent_cols,
881 child_columns: child_cols,
882 });
883 aq.custom = vec![];
884 })
885 }
886
887 #[test]
888 fn test_grouped_sqlx_structs() {
889 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
890 let query = make_grouped_query();
891 let result = generate_with_backend(&query, &*backend).unwrap();
892
893 let row_struct = result.row_struct.as_deref().unwrap();
894 assert!(
895 row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
896 "missing child struct; got:\n{row_struct}"
897 );
898 assert!(
899 row_struct.contains("pub order_id: i32"),
900 "child struct missing order_id field; got:\n{row_struct}"
901 );
902 assert!(
903 row_struct.contains("pub struct GetUsersWithOrdersRow"),
904 "missing parent struct; got:\n{row_struct}"
905 );
906 assert!(
907 row_struct.contains("pub id: i32"),
908 "parent struct missing id field; got:\n{row_struct}"
909 );
910 assert!(
911 row_struct.contains("pub name: String"),
912 "parent struct missing name field; got:\n{row_struct}"
913 );
914 assert!(
915 row_struct.contains("pub email: String"),
916 "parent struct missing email field; got:\n{row_struct}"
917 );
918 assert!(
919 row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
920 "parent struct missing children field; got:\n{row_struct}"
921 );
922 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
923 let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
924 assert!(
925 child_pos < parent_pos,
926 "child struct must be defined before parent struct"
927 );
928
929 assert!(
930 result.model_struct.is_none(),
931 "grouped should not produce a model_struct"
932 );
933 }
934
935 #[test]
936 fn test_grouped_sqlx_query_fn() {
937 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
938 let query = make_grouped_query();
939 let result = generate_with_backend(&query, &*backend).unwrap();
940
941 let query_fn = result.query_fn.as_deref().unwrap();
942 assert!(
943 query_fn.contains("pub async fn get_users_with_orders("),
944 "missing fn; got:\n{query_fn}"
945 );
946 assert!(
947 query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
948 "wrong return type; got:\n{query_fn}"
949 );
950 assert!(
951 query_fn.contains("sqlx::query!("),
952 "grouped fn must use sqlx::query!; got:\n{query_fn}"
953 );
954 assert!(
955 query_fn.contains("GetUsersWithOrdersChildRow {"),
956 "fn must construct child struct; got:\n{query_fn}"
957 );
958 assert!(
959 query_fn.contains("children: vec![child]"),
960 "fn must initialize children vec; got:\n{query_fn}"
961 );
962 assert!(
963 query_fn.contains("parent.children.push(child)"),
964 "fn must fold child into existing parent; got:\n{query_fn}"
965 );
966 assert!(
967 query_fn.contains("Ok(result)"),
968 "fn must return result; got:\n{query_fn}"
969 );
970 }
971
972 #[test]
973 fn test_grouped_python_asyncpg_structs() {
974 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
975 let query = make_grouped_query();
976 let result = generate_with_backend(&query, &*backend).unwrap();
977
978 let row_struct = result.row_struct.as_deref().unwrap();
979 assert!(
980 row_struct.contains("class GetUsersWithOrdersChildRow"),
981 "missing child class; got:\n{row_struct}"
982 );
983 assert!(
984 row_struct.contains("order_id: int"),
985 "child class missing order_id field; got:\n{row_struct}"
986 );
987 assert!(
988 row_struct.contains("class GetUsersWithOrdersRow"),
989 "missing parent class; got:\n{row_struct}"
990 );
991 assert!(
992 row_struct.contains("id: int"),
993 "parent class missing id field; got:\n{row_struct}"
994 );
995 assert!(
996 row_struct.contains("name: str"),
997 "parent class missing name field; got:\n{row_struct}"
998 );
999 assert!(
1000 row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
1001 "parent class missing children field; got:\n{row_struct}"
1002 );
1003 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1004 let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
1005 assert!(
1006 child_pos < parent_pos,
1007 "child class must be defined before parent class"
1008 );
1009 }
1010
1011 #[test]
1012 fn test_grouped_python_asyncpg_query_fn() {
1013 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1014 let query = make_grouped_query();
1015 let result = generate_with_backend(&query, &*backend).unwrap();
1016
1017 let query_fn = result.query_fn.as_deref().unwrap();
1018 assert!(
1019 query_fn.contains("async def get_users_with_orders(conn: Connection)"),
1020 "missing fn; got:\n{query_fn}"
1021 );
1022 assert!(
1023 query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
1024 "wrong return type; got:\n{query_fn}"
1025 );
1026 assert!(
1027 query_fn.contains("conn.fetch("),
1028 "fn must use conn.fetch; got:\n{query_fn}"
1029 );
1030 assert!(
1031 query_fn.contains("GetUsersWithOrdersChildRow("),
1032 "fn must construct child class; got:\n{query_fn}"
1033 );
1034 assert!(
1035 query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
1036 "fn must construct parent with **kwargs; got:\n{query_fn}"
1037 );
1038 assert!(
1039 query_fn.contains("_index"),
1040 "fn must use index dict for O(1) lookup; got:\n{query_fn}"
1041 );
1042 }
1043
1044 struct StubBackend {
1050 manifest: BackendManifest,
1051 }
1052
1053 impl CodegenBackend for StubBackend {
1054 fn name(&self) -> &str {
1055 "stub-backend"
1056 }
1057 fn manifest(&self) -> &BackendManifest {
1058 &self.manifest
1059 }
1060 fn manifest_mut(&mut self) -> &mut BackendManifest {
1061 &mut self.manifest
1062 }
1063 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1064 Ok(String::new())
1065 }
1066 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1067 Ok(String::new())
1068 }
1069 fn generate_query_fn(
1070 &self,
1071 _analyzed: &AnalyzedQuery,
1072 _struct_name: &str,
1073 _columns: &[ResolvedColumn],
1074 _params: &[ResolvedParam],
1075 ) -> Result<String, ScytheError> {
1076 Ok(String::new())
1077 }
1078 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1079 Ok(String::new())
1080 }
1081 fn generate_composite_def(
1082 &self,
1083 _composite: &scythe_core::analyzer::CompositeInfo,
1084 ) -> Result<String, ScytheError> {
1085 Ok(String::new())
1086 }
1087 }
1088
1089 #[test]
1090 fn test_grouped_unsupported_backend_returns_clear_error() {
1091 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1092 let backend = StubBackend { manifest };
1093 let query = make_grouped_query();
1094 let result = generate_with_backend(&query, &backend);
1095 assert!(
1096 result.is_err(),
1097 "stub backend should return an error for grouped queries"
1098 );
1099 let err = result.unwrap_err();
1100 assert!(
1101 err.message.contains("not yet supported"),
1102 "error should contain 'not yet supported', got: {}",
1103 err.message
1104 );
1105 assert!(
1106 err.message.contains("stub-backend"),
1107 "error should name the backend, got: {}",
1108 err.message
1109 );
1110 }
1111
1112 #[test]
1113 fn test_tokio_postgres_backend_basic() {
1114 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1115
1116 let query = make_query(
1117 "ListUsers",
1118 QueryCommand::Many,
1119 "SELECT id, name FROM users",
1120 vec![
1121 AnalyzedColumn {
1122 name: "id".to_string(),
1123 neutral_type: "int32".to_string(),
1124 nullable: false,
1125 ..Default::default()
1126 },
1127 AnalyzedColumn {
1128 name: "name".to_string(),
1129 neutral_type: "string".to_string(),
1130 nullable: false,
1131 ..Default::default()
1132 },
1133 ],
1134 vec![],
1135 );
1136
1137 let result = generate_with_backend(&query, &*backend).unwrap();
1138
1139 let row_struct = result.row_struct.unwrap();
1140 assert!(row_struct.contains("pub struct ListUsersRow"));
1141 assert!(row_struct.contains("pub id: i32"));
1142 assert!(row_struct.contains("pub name: String"));
1143 assert!(row_struct.contains("from_row"));
1144 assert!(row_struct.contains("tokio_postgres::Row"));
1145 assert!(!row_struct.contains("sqlx"));
1146
1147 let query_fn = result.query_fn.unwrap();
1148 assert!(query_fn.contains("pub async fn list_users("));
1149 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1150 assert!(query_fn.contains("tokio_postgres::Error"));
1151 assert!(!query_fn.contains("sqlx"));
1152 }
1153
1154 #[test]
1155 fn test_tokio_postgres_enum() {
1156 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1157
1158 let enum_info = scythe_core::analyzer::EnumInfo {
1159 sql_name: "user_status".to_string(),
1160 values: vec!["active".to_string(), "inactive".to_string()],
1161 };
1162
1163 let def = backend.generate_enum_def(&enum_info).unwrap();
1164 assert!(def.contains("pub enum UserStatus"));
1165 assert!(def.contains("Active"));
1166 assert!(def.contains("Inactive"));
1167 assert!(def.contains("impl std::fmt::Display"));
1168 assert!(def.contains("impl std::str::FromStr"));
1169 assert!(!def.contains("sqlx"));
1170 }
1171
1172 #[test]
1173 fn test_sql_with_least_coalesce_sum_preserved() {
1174 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1175
1176 let query = make_query(
1177 "GetBillingAggregates",
1178 QueryCommand::One,
1179 "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
1180 vec![AnalyzedColumn {
1181 name: "aggregated_free_pages".to_string(),
1182 neutral_type: "int64".to_string(),
1183 nullable: false,
1184 ..Default::default()
1185 }],
1186 vec![AnalyzedParam {
1187 name: "customer_id".to_string(),
1188 neutral_type: "uuid".to_string(),
1189 nullable: false,
1190 position: 1,
1191 }],
1192 );
1193
1194 let result = generate_with_backend(&query, &*backend).unwrap();
1195 let query_fn = result.query_fn.unwrap();
1196
1197 assert!(
1198 query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
1199 "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
1200 );
1201 assert!(
1202 query_fn.contains("as aggregated_free_pages"),
1203 "SQL should preserve alias keyword (as)"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_generated_rust_code_structure() {
1209 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1210
1211 let query = make_query(
1212 "GetUser",
1213 QueryCommand::One,
1214 "SELECT id, name FROM users WHERE id = $1",
1215 vec![
1216 AnalyzedColumn {
1217 name: "id".to_string(),
1218 neutral_type: "int32".to_string(),
1219 nullable: false,
1220 ..Default::default()
1221 },
1222 AnalyzedColumn {
1223 name: "name".to_string(),
1224 neutral_type: "string".to_string(),
1225 nullable: false,
1226 ..Default::default()
1227 },
1228 ],
1229 vec![AnalyzedParam {
1230 name: "id".to_string(),
1231 neutral_type: "int32".to_string(),
1232 nullable: false,
1233 position: 1,
1234 }],
1235 );
1236
1237 let result = generate_with_backend(&query, &*backend).unwrap();
1238 let query_fn = result.query_fn.unwrap();
1239
1240 assert!(query_fn.contains("pub async fn get_user("));
1241 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1242 assert!(query_fn.contains("GetUserRow"));
1243
1244 assert!(query_fn.contains("SELECT id, name FROM users"));
1245 }
1246
1247 fn a_nested_struct() -> NestedStructInfo {
1252 NestedStructInfo {
1253 name: "get_user_posts_row_posts".to_string(),
1254 fields: vec![NestedFieldInfo {
1255 name: "title".to_string(),
1256 neutral_type: "string".to_string(),
1257 nullable: false,
1258 }],
1259 }
1260 }
1261
1262 #[test]
1263 fn test_nested_struct_shape_unwraps_array() {
1264 let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
1265 assert!(shape.is_array);
1266 assert!(!shape.element_nullable);
1267 assert_eq!(shape.name, "GetUserPostsRowPosts");
1268 }
1269
1270 #[test]
1273 fn test_nested_struct_shape_unwraps_nullable_array_element() {
1274 let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
1275 assert!(shape.is_array);
1276 assert!(shape.element_nullable);
1277 assert_eq!(shape.name, "GetUserPostsRowPosts");
1278 }
1279
1280 #[test]
1281 fn test_nested_struct_shape_bare() {
1282 let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
1283 assert!(!shape.is_array);
1284 assert_eq!(shape.name, "GetPostAsJsonRowPost");
1285 }
1286
1287 #[test]
1293 fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
1294 assert!(nested_struct_shape("json_typed<EventData>").is_none());
1295 assert!(nested_struct_pascal_name("json_typed<EventData>").is_none());
1296 }
1297
1298 #[test]
1299 fn test_nested_struct_pascal_name_none_for_ordinary_types() {
1300 assert_eq!(nested_struct_pascal_name("int32"), None);
1301 assert_eq!(nested_struct_pascal_name("json"), None);
1302 assert_eq!(nested_struct_pascal_name("array<int32>"), None);
1303 }
1304
1305 #[test]
1306 fn test_degrade_unsupported_nested_structs_rewrites_to_plain_json() {
1307 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1308 let backend = StubBackend { manifest };
1309 let nested = a_nested_struct();
1310 let columns = vec![
1311 AnalyzedColumn {
1312 name: "id".to_string(),
1313 neutral_type: "int32".to_string(),
1314 nullable: false,
1315 ..Default::default()
1316 },
1317 AnalyzedColumn {
1318 name: "posts".to_string(),
1319 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1320 nullable: true,
1321 ..Default::default()
1322 },
1323 ];
1324
1325 let (degraded, defs) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1326
1327 assert_eq!(
1328 degraded[0].neutral_type, "int32",
1329 "an unrelated column must be untouched"
1330 );
1331 assert_eq!(
1332 degraded[1].neutral_type, "json",
1333 "StubBackend does not override generate_nested_struct_def, so it must degrade to plain json"
1334 );
1335 assert!(
1336 defs.is_empty(),
1337 "an unsupported backend must not emit a struct definition"
1338 );
1339 }
1340
1341 struct NestedSupportingBackend {
1344 manifest: BackendManifest,
1345 }
1346
1347 impl CodegenBackend for NestedSupportingBackend {
1348 fn name(&self) -> &str {
1349 "nested-supporting-backend"
1350 }
1351 fn manifest(&self) -> &BackendManifest {
1352 &self.manifest
1353 }
1354 fn manifest_mut(&mut self) -> &mut BackendManifest {
1355 &mut self.manifest
1356 }
1357 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1358 Ok(String::new())
1359 }
1360 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1361 Ok(String::new())
1362 }
1363 fn generate_query_fn(
1364 &self,
1365 _analyzed: &AnalyzedQuery,
1366 _struct_name: &str,
1367 _columns: &[ResolvedColumn],
1368 _params: &[ResolvedParam],
1369 ) -> Result<String, ScytheError> {
1370 Ok(String::new())
1371 }
1372 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1373 Ok(String::new())
1374 }
1375 fn generate_composite_def(
1376 &self,
1377 _composite: &scythe_core::analyzer::CompositeInfo,
1378 ) -> Result<String, ScytheError> {
1379 Ok(String::new())
1380 }
1381 fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
1382 Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
1383 }
1384 }
1385
1386 #[test]
1387 fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
1388 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1389 let backend = NestedSupportingBackend { manifest };
1390 let nested = a_nested_struct();
1391 let columns = vec![AnalyzedColumn {
1392 name: "posts".to_string(),
1393 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1394 nullable: true,
1395 ..Default::default()
1396 }];
1397
1398 let (degraded, defs) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1399
1400 assert_eq!(
1401 degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
1402 "a supported backend must leave the nested reference untouched"
1403 );
1404 assert_eq!(defs.len(), 1);
1405 assert_eq!(defs[0].name, "get_user_posts_row_posts");
1406 assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
1407 }
1408
1409 #[test]
1415 fn test_unopted_backend_output_is_byte_identical_to_plain_json_baseline() {
1416 let backend = get_backend("java-jdbc", "postgresql").unwrap();
1417
1418 let baseline = make_query(
1419 "GetUserPosts",
1420 QueryCommand::Many,
1421 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1422 vec![AnalyzedColumn {
1423 name: "posts".to_string(),
1424 neutral_type: "json".to_string(),
1425 nullable: true,
1426 ..Default::default()
1427 }],
1428 vec![],
1429 );
1430
1431 let mut nested_query = baseline.clone();
1432 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1433 nested_query.nested_structs = vec![a_nested_struct()];
1434
1435 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1436 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1437
1438 assert_eq!(
1439 baseline_result.row_struct, nested_result.row_struct,
1440 "row struct output must match byte for byte"
1441 );
1442 assert_eq!(
1443 baseline_result.query_fn, nested_result.query_fn,
1444 "query fn output must match byte for byte"
1445 );
1446 assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1447 }
1448}