1pub mod backend_options;
2pub mod backend_trait;
3pub mod backends;
4pub mod overrides;
5pub mod provenance;
6pub mod resolve;
7pub mod sql_literal;
8pub mod validation;
9
10pub use backend_trait::{
11 CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
12};
13pub use backends::get_backend;
14pub use overrides::TypeOverride;
15
16use ahash::AHashSet;
17use scythe_backend::manifest::BackendManifest;
18use scythe_backend::naming::{NamingConfig, apply_case, row_struct_name, sanitize_for_identifier, to_pascal_case};
19
20use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, CompositeInfo, EnumInfo, NestedStructInfo};
21use scythe_core::errors::{ErrorCode, ScytheError};
22use scythe_core::parser::QueryCommand;
23
24const GROUPED_CHILDREN_FIELD: &str = "children";
30
31#[derive(Debug, Clone, Default)]
32#[non_exhaustive]
33pub struct GeneratedCode {
34 pub query_fn: Option<String>,
35 pub row_struct: Option<String>,
36 pub model_struct: Option<String>,
37 pub enum_def: Option<String>,
38 pub nested_struct_defs: Vec<NestedStructDef>,
49 pub degraded_nested_structs: Vec<NestedStructDegradation>,
75}
76
77impl GeneratedCode {
78 #[must_use]
98 pub fn build(init: impl FnOnce(&mut Self)) -> Self {
99 let mut code = Self::default();
100 init(&mut code);
101 code
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct NestedStructDef {
109 pub name: String,
112 pub code: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct NestedStructDegradation {
123 pub column: String,
126 pub struct_name: String,
130 pub fallback_type: &'static str,
134 pub backend: String,
137}
138
139pub fn singularize(name: &str) -> String {
141 if let Some(stem) = name.strip_suffix("ies") {
142 format!("{stem}y")
143 } else if name.ends_with("sses")
144 || name.ends_with("shes")
145 || name.ends_with("ches")
146 || name.ends_with("xes")
147 || name.ends_with("zes")
148 || name.ends_with("ses")
149 {
150 name[..name.len() - 2].to_string()
151 } else if name.ends_with('s') && !name.ends_with("ss") {
152 name[..name.len() - 1].to_string()
153 } else {
154 name.to_string()
155 }
156}
157
158pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
160 let backend = get_backend(backend_name, "postgresql")?;
161 Ok(backend.manifest().clone())
162}
163
164pub fn model_struct_name(table_name: &str, naming: &NamingConfig) -> String {
188 let sanitized = sanitize_for_identifier(table_name);
189 apply_case(&singularize(&sanitized), &naming.struct_case).into_owned()
190}
191
192fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
194 if let Some(ref table_name) = analyzed.source_table {
195 model_struct_name(table_name, &manifest.naming)
196 } else {
197 row_struct_name(&analyzed.name, &manifest.naming)
198 }
199}
200
201pub fn generate_with_backend(
203 analyzed: &AnalyzedQuery,
204 backend: &dyn CodegenBackend,
205) -> Result<GeneratedCode, ScytheError> {
206 generate_with_backend_and_overrides(analyzed, backend, &[])
207}
208
209pub fn generate_with_backend_and_overrides(
211 analyzed: &AnalyzedQuery,
212 backend: &dyn CodegenBackend,
213 overrides: &[TypeOverride],
214) -> Result<GeneratedCode, ScytheError> {
215 generate_with_backend_and_overrides_and_nested_composites(analyzed, backend, overrides, &AHashSet::new())
216}
217
218pub fn generate_with_backend_and_overrides_and_nested_composites(
220 analyzed: &AnalyzedQuery,
221 backend: &dyn CodegenBackend,
222 overrides: &[TypeOverride],
223 nested_composite_names: &AHashSet<String>,
224) -> Result<GeneratedCode, ScytheError> {
225 let manifest = backend.manifest();
226 let source_table = analyzed.source_table.as_deref().unwrap_or("");
227
228 let (degraded_columns, nested_struct_defs, nested_degradations) = if analyzed.nested_structs.is_empty() {
241 (None, Vec::new(), Vec::new())
242 } else {
243 let (cols, defs, degradations) =
244 degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
245 (Some(cols), defs, degradations)
246 };
247 let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
248
249 let columns = resolve::resolve_columns(
250 degraded_columns.as_deref().unwrap_or(&analyzed.columns),
251 manifest,
252 overrides,
253 )?;
254 let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
255
256 let mut result = GeneratedCode::default();
257
258 let struct_name = determine_struct_name(analyzed, manifest);
263 let needs_row_struct = matches!(
264 analyzed.command,
265 QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
266 );
267 let declared_struct_name = (needs_row_struct && !analyzed.columns.is_empty()).then_some(struct_name.as_str());
272
273 let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs, declared_struct_name)?;
274 if !enum_def.is_empty() {
275 result.enum_def = Some(enum_def);
276 }
277
278 if needs_row_struct && !analyzed.columns.is_empty() {
279 if let Some(ref table_name) = analyzed.source_table {
280 result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
281 } else {
282 result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
283 }
284 }
285
286 let mut extra_defs = String::new();
287 let reachable_composites = reachable_composite_names(analyzed, &nested_refs);
288 for comp in composites_in_dependency_order(&analyzed.composites) {
289 if !reachable_composites.contains(comp.sql_name.as_str()) {
299 continue;
300 }
301 let from_nested = nested_refs.composites.contains(comp.sql_name.as_str())
302 || nested_composite_names.contains(comp.sql_name.as_str());
303 if !extra_defs.is_empty() {
304 extra_defs.push_str("\n\n");
305 }
306 if from_nested {
307 extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
308 } else {
309 extra_defs.push_str(&backend.generate_composite_def(comp)?);
310 }
311 }
312 if !extra_defs.is_empty() {
313 if let Some(ref mut existing) = result.model_struct {
314 existing.push_str("\n\n");
315 existing.push_str(&extra_defs);
316 } else {
317 result.model_struct = Some(extra_defs);
318 }
319 }
320 result.nested_struct_defs = nested_struct_defs;
321 result.degraded_nested_structs = nested_degradations;
322
323 if analyzed.command == QueryCommand::Grouped {
324 let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
325 ScytheError::new(
326 ErrorCode::InternalError,
327 format!(
328 "query '{}' is :grouped but is missing @group_by annotation",
329 analyzed.name
330 ),
331 )
332 })?;
333
334 let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
335 let child_struct_name = {
336 let suffix = &manifest.naming.row_suffix;
337 let base = parent_struct_name.trim_end_matches(suffix.as_str());
338 format!("{}Child{}", base, suffix)
339 };
340
341 let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
347 (None, None)
348 } else {
349 let (dp, _, parent_degradations) =
350 degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
351 let (dc, _, child_degradations) =
352 degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
353 result.degraded_nested_structs.extend(parent_degradations);
354 result.degraded_nested_structs.extend(child_degradations);
355 (Some(dp), Some(dc))
356 };
357 let parent_cols = resolve::resolve_columns(
358 degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
359 manifest,
360 overrides,
361 )?;
362 let child_cols = resolve::resolve_columns(
363 degraded_child.as_deref().unwrap_or(&group_by.child_columns),
364 manifest,
365 overrides,
366 )?;
367
368 let children_field_name = scythe_backend::naming::field_name(GROUPED_CHILDREN_FIELD, &manifest.naming);
380 resolve::check_field_name_collisions(
381 parent_cols
382 .iter()
383 .map(|c| (c.name.as_str(), c.field_name.as_str()))
384 .chain(std::iter::once((
385 "<synthesized :grouped children field>",
386 children_field_name.as_ref(),
387 ))),
388 "parent columns",
389 &manifest.naming,
390 )?;
391
392 result.row_struct = Some(backend.generate_grouped_structs(
393 &parent_struct_name,
394 &child_struct_name,
395 &parent_cols,
396 &child_cols,
397 &group_by.key_column,
398 )?);
399 result.query_fn = Some(
400 backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
401 analyzed,
402 parent_struct_name: &parent_struct_name,
403 child_struct_name: &child_struct_name,
404 all_columns: &columns,
405 parent_columns: &parent_cols,
406 child_columns: &child_cols,
407 params: ¶ms,
408 key_column: &group_by.key_column,
409 })?,
410 );
411 } else {
412 result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
413 }
414
415 Ok(result)
416}
417
418#[derive(Debug, Default)]
434struct NestedTypeRefs {
435 enums: ahash::AHashSet<String>,
436 composites: ahash::AHashSet<String>,
437}
438
439impl NestedTypeRefs {
440 fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
441 let mut refs = Self::default();
442 for nested in &analyzed.nested_structs {
443 if !supported.iter().any(|def| def.name == nested.name) {
444 continue;
445 }
446 for field in &nested.fields {
447 let unwrapped = unwrap_containers(&field.neutral_type);
448 if let Some(name) = unwrapped.strip_prefix("enum::") {
449 refs.enums.insert(name.to_string());
450 } else if let Some(name) = unwrapped.strip_prefix("composite::") {
451 refs.composites.insert(name.to_string());
452 }
453 }
454 }
455 refs
456 }
457}
458
459fn composites_in_dependency_order(composites: &[CompositeInfo]) -> Vec<&CompositeInfo> {
484 let by_name: ahash::AHashMap<&str, &CompositeInfo> = composites.iter().map(|c| (c.sql_name.as_str(), c)).collect();
485 let mut ordered: Vec<&CompositeInfo> = Vec::with_capacity(composites.len());
486 let mut emitted: ahash::AHashSet<&str> = ahash::AHashSet::new();
487 let mut visiting: ahash::AHashSet<&str> = ahash::AHashSet::new();
488
489 fn visit<'a>(
490 comp: &'a CompositeInfo,
491 by_name: &ahash::AHashMap<&'a str, &'a CompositeInfo>,
492 emitted: &mut ahash::AHashSet<&'a str>,
493 visiting: &mut ahash::AHashSet<&'a str>,
494 ordered: &mut Vec<&'a CompositeInfo>,
495 ) {
496 let name = comp.sql_name.as_str();
497 if emitted.contains(name) || !visiting.insert(name) {
498 return;
499 }
500 for field in &comp.fields {
501 if let Some(dep) = unwrap_containers(&field.neutral_type).strip_prefix("composite::")
502 && let Some(dep) = by_name.get(dep)
503 {
504 visit(dep, by_name, emitted, visiting, ordered);
505 }
506 }
507 visiting.remove(name);
508 if emitted.insert(name) {
509 ordered.push(comp);
510 }
511 }
512
513 for comp in composites {
514 visit(comp, &by_name, &mut emitted, &mut visiting, &mut ordered);
515 }
516 ordered
517}
518
519pub(crate) fn unwrap_containers(neutral: &str) -> &str {
537 let mut current = neutral;
538 loop {
539 if let Some(inner) = current.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
540 current = inner.trim();
541 } else if let Some(inner) = current.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
542 current = inner.trim();
543 } else {
544 return current;
545 }
546 }
547}
548
549fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
557 let neutral = format!("{prefix}{sql_name}");
558 analyzed
559 .columns
560 .iter()
561 .any(|col| unwrap_containers(&col.neutral_type) == neutral)
562 || analyzed
563 .params
564 .iter()
565 .any(|param| unwrap_containers(¶m.neutral_type) == neutral)
566}
567
568fn reachable_composite_names<'a>(
583 analyzed: &'a AnalyzedQuery,
584 nested_refs: &NestedTypeRefs,
585) -> ahash::AHashSet<&'a str> {
586 let mut reachable: ahash::AHashSet<&str> = ahash::AHashSet::new();
587 let mut worklist: Vec<&str> = analyzed
588 .composites
589 .iter()
590 .filter(|comp| {
591 nested_refs.composites.contains(comp.sql_name.as_str())
592 || type_referenced_by_columns(analyzed, "composite::", &comp.sql_name)
593 })
594 .map(|comp| comp.sql_name.as_str())
595 .collect();
596
597 while let Some(name) = worklist.pop() {
598 if !reachable.insert(name) {
599 continue;
600 }
601 let Some(comp) = analyzed.composites.iter().find(|c| c.sql_name == name) else {
602 continue;
603 };
604 for field in &comp.fields {
605 if let Some(nested_name) = unwrap_containers(&field.neutral_type).strip_prefix("composite::") {
606 worklist.push(nested_name);
607 }
608 }
609 }
610 reachable
611}
612
613pub fn nested_type_is_emitted(
620 analyzed: &AnalyzedQuery,
621 supported: &[NestedStructDef],
622 prefix: &str,
623 sql_name: &str,
624) -> bool {
625 let neutral = format!("{prefix}{sql_name}");
626 analyzed
627 .nested_structs
628 .iter()
629 .filter(|nested| supported.iter().any(|def| def.name == nested.name))
630 .any(|nested| {
631 nested
632 .fields
633 .iter()
634 .any(|field| unwrap_containers(&field.neutral_type) == neutral)
635 })
636}
637
638fn generate_enum_defs_via_backend(
646 analyzed: &AnalyzedQuery,
647 backend: &dyn CodegenBackend,
648 nested_refs: &NestedTypeRefs,
649 declared_struct_name: Option<&str>,
650) -> Result<String, ScytheError> {
651 use ahash::AHashSet;
652 use std::fmt::Write;
653
654 let naming = &backend.manifest().naming;
655
656 let enum_sources: Vec<&str> = analyzed
657 .columns
658 .iter()
659 .filter_map(|col| unwrap_containers(&col.neutral_type).strip_prefix("enum::"))
660 .chain(
661 analyzed
662 .params
663 .iter()
664 .filter_map(|p| unwrap_containers(&p.neutral_type).strip_prefix("enum::")),
665 )
666 .chain(nested_refs.enums.iter().map(String::as_str))
667 .collect();
668
669 let mut seen_enums: AHashSet<&str> = AHashSet::new();
670 let unique_sources: Vec<&str> = enum_sources
671 .into_iter()
672 .filter(|name| seen_enums.insert(*name))
673 .collect();
674
675 let generated_names: Vec<(&str, String)> = unique_sources
681 .iter()
682 .map(|sql_name| (*sql_name, scythe_backend::naming::enum_type_name(sql_name, naming)))
683 .collect();
684 let query_type_entry = declared_struct_name.map(|name| ("<query row/model type>", name.to_string()));
685 resolve::check_type_name_collisions(
686 generated_names
687 .iter()
688 .chain(query_type_entry.iter())
689 .map(|(source, name)| (*source, name.as_str())),
690 "enum and query type names",
691 )?;
692
693 let mut out = String::new();
694 for sql_name in unique_sources {
695 if !out.is_empty() {
696 let _ = writeln!(out);
697 }
698
699 let stub_info;
700 let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
701 Some(info) => info,
702 None => {
703 stub_info = EnumInfo {
704 sql_name: sql_name.to_string(),
705 values: vec![],
706 };
707 &stub_info
708 }
709 };
710 let variant_names: Vec<(&str, String)> = enum_info
715 .values
716 .iter()
717 .map(|value| (value.as_str(), scythe_backend::naming::enum_variant_name(value, naming)))
718 .collect();
719 resolve::check_enum_variant_collisions(
720 variant_names.iter().map(|(source, name)| (*source, name.as_str())),
721 sql_name,
722 )?;
723
724 if nested_refs.enums.contains(sql_name) {
725 out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
726 } else {
727 out.push_str(&backend.generate_enum_def(enum_info)?);
728 }
729 }
730
731 Ok(out)
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
736pub(crate) struct NestedColumnShape<'a> {
737 pub(crate) is_array: bool,
739 pub(crate) element_nullable: bool,
743 pub(crate) name: &'a str,
745}
746
747pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
766 let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
767 let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
768 return Some(NestedColumnShape {
769 is_array: false,
770 element_nullable: false,
771 name: inner,
772 });
773 };
774 match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
775 Some(name) => Some(NestedColumnShape {
776 is_array: true,
777 element_nullable: true,
778 name,
779 }),
780 None => Some(NestedColumnShape {
781 is_array: true,
782 element_nullable: false,
783 name: element,
784 }),
785 }
786}
787
788pub type DegradedNestedStructs = (Vec<AnalyzedColumn>, Vec<NestedStructDef>, Vec<NestedStructDegradation>);
828
829pub fn degrade_unsupported_nested_structs(
830 columns: &[AnalyzedColumn],
831 nested_structs: &[NestedStructInfo],
832 backend: &dyn CodegenBackend,
833) -> Result<DegradedNestedStructs, ScytheError> {
834 if nested_structs.is_empty() {
835 return Ok((columns.to_vec(), Vec::new(), Vec::new()));
836 }
837
838 use ahash::AHashSet;
839
840 let mut unsupported: AHashSet<String> = AHashSet::new();
841 let mut defs: Vec<NestedStructDef> = Vec::new();
842 for nested in nested_structs {
843 match backend.generate_nested_struct_def(nested)? {
844 Some(def) => defs.push(NestedStructDef {
845 name: nested.name.clone(),
846 code: def,
847 }),
848 None => {
849 unsupported.insert(to_pascal_case(&nested.name).into_owned());
850 }
851 }
852 }
853
854 if unsupported.is_empty() {
855 return Ok((columns.to_vec(), defs, Vec::new()));
856 }
857
858 let supports_json_array = backend.manifest().types.scalars.contains_key("json_array");
859 let backend_name = backend.name().to_string();
860
861 let mut degradations: Vec<NestedStructDegradation> = Vec::new();
862 let degraded = columns
863 .iter()
864 .cloned()
865 .map(|mut col| {
866 if let Some(shape) = nested_struct_shape(&col.neutral_type)
867 && unsupported.contains(shape.name)
868 {
869 let fallback_type = if shape.is_array && supports_json_array {
870 "json_array"
871 } else {
872 "json"
873 };
874 degradations.push(NestedStructDegradation {
875 column: col.name.clone(),
876 struct_name: shape.name.to_string(),
877 fallback_type,
878 backend: backend_name.clone(),
879 });
880 col.neutral_type = fallback_type.to_string();
881 }
882 col
883 })
884 .collect();
885
886 Ok((degraded, defs, degradations))
887}
888
889pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
891 let backend = get_backend("rust-sqlx", "postgresql")?;
892 generate_with_backend(analyzed, &*backend)
893}
894
895pub fn generate_single_enum_def_with_backend(
904 enum_info: &EnumInfo,
905 backend: &dyn CodegenBackend,
906 reachable_from_nested: bool,
907) -> Result<String, ScytheError> {
908 if reachable_from_nested {
909 backend.generate_enum_def_for_nested(enum_info)
910 } else {
911 backend.generate_enum_def(enum_info)
912 }
913}
914
915pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
918 use scythe_backend::naming::{enum_type_name, enum_variant_name};
919 use std::fmt::Write;
920
921 let mut out = String::with_capacity(256);
922 let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
923
924 let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
925 let _ = writeln!(
926 out,
927 "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
928 enum_info.sql_name
929 );
930 let _ = writeln!(out, "pub enum {type_name} {{");
931
932 for value in &enum_info.values {
933 let variant = enum_variant_name(value, &manifest.naming);
934 let _ = writeln!(out, " {variant},");
935 }
936
937 let _ = write!(out, "}}");
938 out
939}
940
941pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
943 let b = backends::sqlx::SqlxBackend::new("postgresql")?;
944 Ok(b.manifest().clone())
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
951 use scythe_core::parser::QueryCommand;
952
953 fn make_query(
954 name: &str,
955 command: QueryCommand,
956 sql: &str,
957 columns: Vec<AnalyzedColumn>,
958 params: Vec<AnalyzedParam>,
959 ) -> AnalyzedQuery {
960 AnalyzedQuery::build(|aq| {
961 aq.name = name.to_string();
962 aq.command = command;
963 aq.sql = sql.to_string();
964 aq.columns = columns;
965 aq.params = params;
966 aq.deprecated = None;
967 aq.source_table = None;
968 aq.composites = Vec::new();
969 aq.enums = Vec::new();
970 aq.optional_params = Vec::new();
971 aq.group_by = None;
972 aq.custom = Vec::new();
973 })
974 }
975
976 fn test_naming() -> NamingConfig {
977 NamingConfig {
978 struct_case: "PascalCase".to_string(),
979 fn_case: "snake_case".to_string(),
980 enum_variant_case: "PascalCase".to_string(),
981 row_suffix: "Row".to_string(),
982 field_case: "snake_case".to_string(),
983 reserved: Vec::new(),
984 reserved_bindings: Vec::new(),
985 sanitize_field_names: false,
986 }
987 }
988
989 #[test]
997 fn model_struct_name_sanitizes_a_schema_qualified_dot() {
998 assert_eq!(model_struct_name("app.widgets", &test_naming()), "AppWidget");
999 }
1000
1001 #[test]
1002 fn test_generate_select_many() {
1003 let query = make_query(
1004 "ListUsers",
1005 QueryCommand::Many,
1006 "SELECT id, name, email FROM users",
1007 vec![
1008 AnalyzedColumn {
1009 name: "id".to_string(),
1010 neutral_type: "int32".to_string(),
1011 nullable: false,
1012 ..Default::default()
1013 },
1014 AnalyzedColumn {
1015 name: "name".to_string(),
1016 neutral_type: "string".to_string(),
1017 nullable: false,
1018 ..Default::default()
1019 },
1020 AnalyzedColumn {
1021 name: "email".to_string(),
1022 neutral_type: "string".to_string(),
1023 nullable: true,
1024 ..Default::default()
1025 },
1026 ],
1027 vec![],
1028 );
1029
1030 let result = generate(&query).unwrap();
1031
1032 let row_struct = result.row_struct.unwrap();
1033 assert!(row_struct.contains("pub struct ListUsersRow"));
1034 assert!(row_struct.contains("pub id: i32"));
1035 assert!(row_struct.contains("pub name: String"));
1036 assert!(row_struct.contains("pub email: Option<String>"));
1037
1038 let query_fn = result.query_fn.unwrap();
1039 assert!(query_fn.contains("pub async fn list_users("));
1040 assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
1041 assert!(query_fn.contains(".fetch_all(pool)"));
1042 }
1043
1044 #[test]
1045 fn test_generate_select_one_with_param() {
1046 let query = make_query(
1047 "GetUser",
1048 QueryCommand::One,
1049 "SELECT id, name FROM users WHERE id = $1",
1050 vec![
1051 AnalyzedColumn {
1052 name: "id".to_string(),
1053 neutral_type: "int32".to_string(),
1054 nullable: false,
1055 ..Default::default()
1056 },
1057 AnalyzedColumn {
1058 name: "name".to_string(),
1059 neutral_type: "string".to_string(),
1060 nullable: false,
1061 ..Default::default()
1062 },
1063 ],
1064 vec![AnalyzedParam {
1065 name: "id".to_string(),
1066 neutral_type: "int32".to_string(),
1067 nullable: false,
1068 position: 1,
1069 source_relation: None,
1070 }],
1071 );
1072
1073 let result = generate(&query).unwrap();
1074
1075 let query_fn = result.query_fn.unwrap();
1076 assert!(query_fn.contains("pub async fn get_user("));
1077 assert!(query_fn.contains("id: i32"));
1078 assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
1079 assert!(query_fn.contains(".fetch_one(pool)"));
1080 }
1081
1082 #[test]
1083 fn test_generate_exec() {
1084 let query = make_query(
1085 "DeleteUser",
1086 QueryCommand::Exec,
1087 "DELETE FROM users WHERE id = $1",
1088 vec![],
1089 vec![AnalyzedParam {
1090 name: "id".to_string(),
1091 neutral_type: "int32".to_string(),
1092 nullable: false,
1093 position: 1,
1094 source_relation: None,
1095 }],
1096 );
1097
1098 let result = generate(&query).unwrap();
1099
1100 assert!(result.row_struct.is_none());
1101
1102 let query_fn = result.query_fn.unwrap();
1103 assert!(query_fn.contains("pub async fn delete_user("));
1104 assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
1105 assert!(query_fn.contains(".execute(pool)"));
1106 }
1107
1108 #[test]
1109 fn test_generate_with_enum_column() {
1110 let query = make_query(
1111 "GetUserStatus",
1112 QueryCommand::One,
1113 "SELECT id, status FROM users WHERE id = $1",
1114 vec![
1115 AnalyzedColumn {
1116 name: "id".to_string(),
1117 neutral_type: "int32".to_string(),
1118 nullable: false,
1119 ..Default::default()
1120 },
1121 AnalyzedColumn {
1122 name: "status".to_string(),
1123 neutral_type: "enum::user_status".to_string(),
1124 nullable: false,
1125 ..Default::default()
1126 },
1127 ],
1128 vec![AnalyzedParam {
1129 name: "id".to_string(),
1130 neutral_type: "int32".to_string(),
1131 nullable: false,
1132 position: 1,
1133 source_relation: None,
1134 }],
1135 );
1136
1137 let result = generate(&query).unwrap();
1138
1139 assert!(result.enum_def.is_some());
1140 let enum_def = result.enum_def.unwrap();
1141 assert!(enum_def.contains("pub enum UserStatus"));
1142 assert!(enum_def.contains("type_name = \"user_status\""));
1143
1144 let row_struct = result.row_struct.unwrap();
1145 assert!(row_struct.contains("pub status: UserStatus"));
1146 }
1147
1148 #[test]
1149 fn test_singularize_basic() {
1150 assert_eq!(singularize("users"), "user");
1151 assert_eq!(singularize("orders"), "order");
1152 assert_eq!(singularize("posts"), "post");
1153 }
1154
1155 #[test]
1156 fn test_singularize_ies() {
1157 assert_eq!(singularize("categories"), "category");
1158 assert_eq!(singularize("entries"), "entry");
1159 }
1160
1161 #[test]
1162 fn test_singularize_sses() {
1163 assert_eq!(singularize("addresses"), "address");
1164 assert_eq!(singularize("classes"), "class");
1165 }
1166
1167 #[test]
1168 fn test_singularize_no_change() {
1169 assert_eq!(singularize("status"), "statu");
1170 assert_eq!(singularize("boss"), "boss");
1171 assert_eq!(singularize("address"), "address");
1172 }
1173
1174 #[test]
1175 fn test_singularize_shes_ches_xes() {
1176 assert_eq!(singularize("batches"), "batch");
1177 assert_eq!(singularize("boxes"), "box");
1178 assert_eq!(singularize("wishes"), "wish");
1179 }
1180
1181 fn make_grouped_query() -> AnalyzedQuery {
1182 let parent_cols = vec![
1183 AnalyzedColumn {
1184 name: "id".to_string(),
1185 neutral_type: "int32".to_string(),
1186 nullable: false,
1187 ..Default::default()
1188 },
1189 AnalyzedColumn {
1190 name: "name".to_string(),
1191 neutral_type: "string".to_string(),
1192 nullable: false,
1193 ..Default::default()
1194 },
1195 AnalyzedColumn {
1196 name: "email".to_string(),
1197 neutral_type: "string".to_string(),
1198 nullable: false,
1199 ..Default::default()
1200 },
1201 ];
1202 let child_cols = vec![
1203 AnalyzedColumn {
1204 name: "order_id".to_string(),
1205 neutral_type: "int32".to_string(),
1206 nullable: false,
1207 ..Default::default()
1208 },
1209 AnalyzedColumn {
1210 name: "total".to_string(),
1211 neutral_type: "decimal".to_string(),
1212 nullable: true,
1213 ..Default::default()
1214 },
1215 AnalyzedColumn {
1216 name: "order_date".to_string(),
1217 neutral_type: "datetime".to_string(),
1218 nullable: false,
1219 ..Default::default()
1220 },
1221 ];
1222 let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
1223
1224 AnalyzedQuery::build(|aq| {
1225 aq.name = "GetUsersWithOrders".to_string();
1226 aq.command = QueryCommand::Grouped;
1227 aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
1228 SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
1229 FROM users u\n\
1230 JOIN orders o ON o.user_id = u.id"
1231 .to_string();
1232 aq.columns = all_cols;
1233 aq.params = vec![];
1234 aq.deprecated = None;
1235 aq.source_table = None;
1236 aq.composites = vec![];
1237 aq.enums = vec![];
1238 aq.optional_params = vec![];
1239 aq.group_by = Some(GroupByConfig {
1240 table: "users".to_string(),
1241 key_column: "id".to_string(),
1242 parent_columns: parent_cols,
1243 child_columns: child_cols,
1244 });
1245 aq.custom = vec![];
1246 })
1247 }
1248
1249 #[test]
1250 fn test_grouped_sqlx_structs() {
1251 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1252 let query = make_grouped_query();
1253 let result = generate_with_backend(&query, &*backend).unwrap();
1254
1255 let row_struct = result.row_struct.as_deref().unwrap();
1256 assert!(
1257 row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
1258 "missing child struct; got:\n{row_struct}"
1259 );
1260 assert!(
1261 row_struct.contains("pub order_id: i32"),
1262 "child struct missing order_id field; got:\n{row_struct}"
1263 );
1264 assert!(
1265 row_struct.contains("pub struct GetUsersWithOrdersRow"),
1266 "missing parent struct; got:\n{row_struct}"
1267 );
1268 assert!(
1269 row_struct.contains("pub id: i32"),
1270 "parent struct missing id field; got:\n{row_struct}"
1271 );
1272 assert!(
1273 row_struct.contains("pub name: String"),
1274 "parent struct missing name field; got:\n{row_struct}"
1275 );
1276 assert!(
1277 row_struct.contains("pub email: String"),
1278 "parent struct missing email field; got:\n{row_struct}"
1279 );
1280 assert!(
1281 row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
1282 "parent struct missing children field; got:\n{row_struct}"
1283 );
1284 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1285 let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
1286 assert!(
1287 child_pos < parent_pos,
1288 "child struct must be defined before parent struct"
1289 );
1290
1291 assert!(
1292 result.model_struct.is_none(),
1293 "grouped should not produce a model_struct"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_grouped_sqlx_query_fn() {
1299 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1300 let query = make_grouped_query();
1301 let result = generate_with_backend(&query, &*backend).unwrap();
1302
1303 let query_fn = result.query_fn.as_deref().unwrap();
1304 assert!(
1305 query_fn.contains("pub async fn get_users_with_orders("),
1306 "missing fn; got:\n{query_fn}"
1307 );
1308 assert!(
1309 query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
1310 "wrong return type; got:\n{query_fn}"
1311 );
1312 assert!(
1313 query_fn.contains("sqlx::query!("),
1314 "grouped fn must use sqlx::query!; got:\n{query_fn}"
1315 );
1316 assert!(
1317 query_fn.contains("GetUsersWithOrdersChildRow {"),
1318 "fn must construct child struct; got:\n{query_fn}"
1319 );
1320 assert!(
1321 query_fn.contains("children: vec![child]"),
1322 "fn must initialize children vec; got:\n{query_fn}"
1323 );
1324 assert!(
1325 query_fn.contains("parent.children.push(child)"),
1326 "fn must fold child into existing parent; got:\n{query_fn}"
1327 );
1328 assert!(
1329 query_fn.contains("Ok(result)"),
1330 "fn must return result; got:\n{query_fn}"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_grouped_python_asyncpg_structs() {
1336 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1337 let query = make_grouped_query();
1338 let result = generate_with_backend(&query, &*backend).unwrap();
1339
1340 let row_struct = result.row_struct.as_deref().unwrap();
1341 assert!(
1342 row_struct.contains("class GetUsersWithOrdersChildRow"),
1343 "missing child class; got:\n{row_struct}"
1344 );
1345 assert!(
1346 row_struct.contains("order_id: int"),
1347 "child class missing order_id field; got:\n{row_struct}"
1348 );
1349 assert!(
1350 row_struct.contains("class GetUsersWithOrdersRow"),
1351 "missing parent class; got:\n{row_struct}"
1352 );
1353 assert!(
1354 row_struct.contains("id: int"),
1355 "parent class missing id field; got:\n{row_struct}"
1356 );
1357 assert!(
1358 row_struct.contains("name: str"),
1359 "parent class missing name field; got:\n{row_struct}"
1360 );
1361 assert!(
1362 row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
1363 "parent class missing children field; got:\n{row_struct}"
1364 );
1365 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1366 let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
1367 assert!(
1368 child_pos < parent_pos,
1369 "child class must be defined before parent class"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_grouped_python_asyncpg_query_fn() {
1375 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1376 let query = make_grouped_query();
1377 let result = generate_with_backend(&query, &*backend).unwrap();
1378
1379 let query_fn = result.query_fn.as_deref().unwrap();
1380 assert!(
1381 query_fn.contains("async def get_users_with_orders(conn: Connection)"),
1382 "missing fn; got:\n{query_fn}"
1383 );
1384 assert!(
1385 query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
1386 "wrong return type; got:\n{query_fn}"
1387 );
1388 assert!(
1389 query_fn.contains("conn.fetch("),
1390 "fn must use conn.fetch; got:\n{query_fn}"
1391 );
1392 assert!(
1393 query_fn.contains("GetUsersWithOrdersChildRow("),
1394 "fn must construct child class; got:\n{query_fn}"
1395 );
1396 assert!(
1397 query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
1398 "fn must construct parent with **kwargs; got:\n{query_fn}"
1399 );
1400 assert!(
1401 query_fn.contains("_index"),
1402 "fn must use index dict for O(1) lookup; got:\n{query_fn}"
1403 );
1404 }
1405
1406 struct StubBackend {
1412 manifest: BackendManifest,
1413 }
1414
1415 impl CodegenBackend for StubBackend {
1416 fn name(&self) -> &str {
1417 "stub-backend"
1418 }
1419 fn manifest(&self) -> &BackendManifest {
1420 &self.manifest
1421 }
1422 fn manifest_mut(&mut self) -> &mut BackendManifest {
1423 &mut self.manifest
1424 }
1425 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1426 Ok(String::new())
1427 }
1428 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1429 Ok(String::new())
1430 }
1431 fn generate_query_fn(
1432 &self,
1433 _analyzed: &AnalyzedQuery,
1434 _struct_name: &str,
1435 _columns: &[ResolvedColumn],
1436 _params: &[ResolvedParam],
1437 ) -> Result<String, ScytheError> {
1438 Ok(String::new())
1439 }
1440 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1441 Ok(String::new())
1442 }
1443 fn generate_composite_def(
1444 &self,
1445 _composite: &scythe_core::analyzer::CompositeInfo,
1446 ) -> Result<String, ScytheError> {
1447 Ok(String::new())
1448 }
1449 }
1450
1451 #[test]
1452 fn test_grouped_unsupported_backend_returns_clear_error() {
1453 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1454 let backend = StubBackend { manifest };
1455 let query = make_grouped_query();
1456 let result = generate_with_backend(&query, &backend);
1457 assert!(
1458 result.is_err(),
1459 "stub backend should return an error for grouped queries"
1460 );
1461 let err = result.unwrap_err();
1462 assert!(
1463 err.message.contains("not yet supported"),
1464 "error should contain 'not yet supported', got: {}",
1465 err.message
1466 );
1467 assert!(
1468 err.message.contains("stub-backend"),
1469 "error should name the backend, got: {}",
1470 err.message
1471 );
1472 }
1473
1474 #[test]
1475 fn test_tokio_postgres_backend_basic() {
1476 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1477
1478 let query = make_query(
1479 "ListUsers",
1480 QueryCommand::Many,
1481 "SELECT id, name FROM users",
1482 vec![
1483 AnalyzedColumn {
1484 name: "id".to_string(),
1485 neutral_type: "int32".to_string(),
1486 nullable: false,
1487 ..Default::default()
1488 },
1489 AnalyzedColumn {
1490 name: "name".to_string(),
1491 neutral_type: "string".to_string(),
1492 nullable: false,
1493 ..Default::default()
1494 },
1495 ],
1496 vec![],
1497 );
1498
1499 let result = generate_with_backend(&query, &*backend).unwrap();
1500
1501 let row_struct = result.row_struct.unwrap();
1502 assert!(row_struct.contains("pub struct ListUsersRow"));
1503 assert!(row_struct.contains("pub id: i32"));
1504 assert!(row_struct.contains("pub name: String"));
1505 assert!(row_struct.contains("from_row"));
1506 assert!(row_struct.contains("tokio_postgres::Row"));
1507 assert!(!row_struct.contains("sqlx"));
1508
1509 let query_fn = result.query_fn.unwrap();
1510 assert!(query_fn.contains("pub async fn list_users("));
1511 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1512 assert!(query_fn.contains("tokio_postgres::Error"));
1513 assert!(!query_fn.contains("sqlx"));
1514 }
1515
1516 #[test]
1517 fn test_tokio_postgres_enum() {
1518 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1519
1520 let enum_info = scythe_core::analyzer::EnumInfo {
1521 sql_name: "user_status".to_string(),
1522 values: vec!["active".to_string(), "inactive".to_string()],
1523 };
1524
1525 let def = backend.generate_enum_def(&enum_info).unwrap();
1526 assert!(def.contains("pub enum UserStatus"));
1527 assert!(def.contains("Active"));
1528 assert!(def.contains("Inactive"));
1529 assert!(def.contains("impl std::fmt::Display"));
1530 assert!(def.contains("impl std::str::FromStr"));
1531 assert!(!def.contains("sqlx"));
1532 }
1533
1534 #[test]
1535 fn test_sql_with_least_coalesce_sum_preserved() {
1536 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1537
1538 let query = make_query(
1539 "GetBillingAggregates",
1540 QueryCommand::One,
1541 "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
1542 vec![AnalyzedColumn {
1543 name: "aggregated_free_pages".to_string(),
1544 neutral_type: "int64".to_string(),
1545 nullable: false,
1546 ..Default::default()
1547 }],
1548 vec![AnalyzedParam {
1549 name: "customer_id".to_string(),
1550 neutral_type: "uuid".to_string(),
1551 nullable: false,
1552 position: 1,
1553 source_relation: None,
1554 }],
1555 );
1556
1557 let result = generate_with_backend(&query, &*backend).unwrap();
1558 let query_fn = result.query_fn.unwrap();
1559
1560 assert!(
1561 query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
1562 "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
1563 );
1564 assert!(
1565 query_fn.contains("as aggregated_free_pages"),
1566 "SQL should preserve alias keyword (as)"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_generated_rust_code_structure() {
1572 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1573
1574 let query = make_query(
1575 "GetUser",
1576 QueryCommand::One,
1577 "SELECT id, name FROM users WHERE id = $1",
1578 vec![
1579 AnalyzedColumn {
1580 name: "id".to_string(),
1581 neutral_type: "int32".to_string(),
1582 nullable: false,
1583 ..Default::default()
1584 },
1585 AnalyzedColumn {
1586 name: "name".to_string(),
1587 neutral_type: "string".to_string(),
1588 nullable: false,
1589 ..Default::default()
1590 },
1591 ],
1592 vec![AnalyzedParam {
1593 name: "id".to_string(),
1594 neutral_type: "int32".to_string(),
1595 nullable: false,
1596 position: 1,
1597 source_relation: None,
1598 }],
1599 );
1600
1601 let result = generate_with_backend(&query, &*backend).unwrap();
1602 let query_fn = result.query_fn.unwrap();
1603
1604 assert!(query_fn.contains("pub async fn get_user("));
1605 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1606 assert!(query_fn.contains("GetUserRow"));
1607
1608 assert!(query_fn.contains("SELECT id, name FROM users"));
1609 }
1610
1611 fn a_nested_struct() -> NestedStructInfo {
1616 NestedStructInfo {
1617 name: "get_user_posts_row_posts".to_string(),
1618 fields: vec![NestedFieldInfo {
1619 name: "title".to_string(),
1620 neutral_type: "string".to_string(),
1621 nullable: false,
1622 }],
1623 }
1624 }
1625
1626 #[test]
1627 fn test_nested_struct_shape_unwraps_array() {
1628 let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
1629 assert!(shape.is_array);
1630 assert!(!shape.element_nullable);
1631 assert_eq!(shape.name, "GetUserPostsRowPosts");
1632 }
1633
1634 #[test]
1637 fn test_nested_struct_shape_unwraps_nullable_array_element() {
1638 let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
1639 assert!(shape.is_array);
1640 assert!(shape.element_nullable);
1641 assert_eq!(shape.name, "GetUserPostsRowPosts");
1642 }
1643
1644 #[test]
1645 fn test_nested_struct_shape_bare() {
1646 let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
1647 assert!(!shape.is_array);
1648 assert_eq!(shape.name, "GetPostAsJsonRowPost");
1649 }
1650
1651 #[test]
1657 fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
1658 assert!(nested_struct_shape("json_typed<EventData>").is_none());
1659 }
1660
1661 #[test]
1662 fn test_nested_struct_shape_none_for_ordinary_types() {
1663 assert!(nested_struct_shape("int32").is_none());
1664 assert!(nested_struct_shape("json").is_none());
1665 assert!(nested_struct_shape("array<int32>").is_none());
1666 }
1667
1668 #[test]
1669 fn test_degrade_unsupported_nested_structs_defaults_to_plain_json() {
1670 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1671 let backend = StubBackend { manifest };
1672 let nested = a_nested_struct();
1673 let columns = vec![
1674 AnalyzedColumn {
1675 name: "id".to_string(),
1676 neutral_type: "int32".to_string(),
1677 nullable: false,
1678 ..Default::default()
1679 },
1680 AnalyzedColumn {
1681 name: "posts".to_string(),
1682 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1683 nullable: true,
1684 ..Default::default()
1685 },
1686 AnalyzedColumn {
1687 name: "profile".to_string(),
1688 neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1689 nullable: true,
1690 ..Default::default()
1691 },
1692 ];
1693
1694 let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1695
1696 assert_eq!(
1697 degraded[0].neutral_type, "int32",
1698 "an unrelated column must be untouched"
1699 );
1700 assert_eq!(
1701 degraded[1].neutral_type, "json",
1702 "a backend without an explicit JSON-array mapping must keep the safe plain-json fallback"
1703 );
1704 assert_eq!(
1705 degraded[2].neutral_type, "json",
1706 "an unsupported row_to_json result must remain a single object"
1707 );
1708 assert!(
1709 defs.is_empty(),
1710 "an unsupported backend must not emit a struct definition"
1711 );
1712
1713 assert_eq!(degradations.len(), 2, "only the two nested columns were rewritten");
1718 assert_eq!(degradations[0].column, "posts");
1719 assert_eq!(degradations[0].struct_name, "GetUserPostsRowPosts");
1720 assert_eq!(degradations[0].fallback_type, "json");
1721 assert_eq!(
1722 degradations[0].backend, "stub-backend",
1723 "the backend the user configured, from CodegenBackend::name -- not the manifest it borrows"
1724 );
1725 assert_eq!(degradations[1].column, "profile");
1726 assert_eq!(degradations[1].struct_name, "GetUserPostsRowPosts");
1727 assert_eq!(degradations[1].fallback_type, "json");
1728 assert_eq!(degradations[1].backend, "stub-backend");
1729 }
1730
1731 #[test]
1732 fn test_degrade_unsupported_nested_structs_uses_explicit_json_array_marker() {
1733 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1734 let nested = a_nested_struct();
1735 let columns = vec![
1736 AnalyzedColumn {
1737 name: "posts".to_string(),
1738 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1739 nullable: true,
1740 ..Default::default()
1741 },
1742 AnalyzedColumn {
1743 name: "profile".to_string(),
1744 neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1745 nullable: true,
1746 ..Default::default()
1747 },
1748 ];
1749
1750 let (degraded, defs, degradations) =
1751 degrade_unsupported_nested_structs(&columns, &[nested], &*backend).unwrap();
1752
1753 assert_eq!(degraded[0].neutral_type, "json_array");
1754 assert_eq!(degraded[1].neutral_type, "json");
1755 assert!(defs.is_empty());
1756
1757 assert_eq!(degradations.len(), 2);
1761 assert_eq!(degradations[0].fallback_type, "json_array");
1762 assert_eq!(degradations[0].backend, "python-asyncpg");
1763 assert_eq!(degradations[1].fallback_type, "json");
1764 }
1765
1766 struct NestedSupportingBackend {
1769 manifest: BackendManifest,
1770 }
1771
1772 impl CodegenBackend for NestedSupportingBackend {
1773 fn name(&self) -> &str {
1774 "nested-supporting-backend"
1775 }
1776 fn manifest(&self) -> &BackendManifest {
1777 &self.manifest
1778 }
1779 fn manifest_mut(&mut self) -> &mut BackendManifest {
1780 &mut self.manifest
1781 }
1782 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1783 Ok(String::new())
1784 }
1785 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1786 Ok(String::new())
1787 }
1788 fn generate_query_fn(
1789 &self,
1790 _analyzed: &AnalyzedQuery,
1791 _struct_name: &str,
1792 _columns: &[ResolvedColumn],
1793 _params: &[ResolvedParam],
1794 ) -> Result<String, ScytheError> {
1795 Ok(String::new())
1796 }
1797 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1798 Ok(String::new())
1799 }
1800 fn generate_composite_def(
1801 &self,
1802 _composite: &scythe_core::analyzer::CompositeInfo,
1803 ) -> Result<String, ScytheError> {
1804 Ok(String::new())
1805 }
1806 fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
1807 Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
1808 }
1809 }
1810
1811 #[test]
1812 fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
1813 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1814 let backend = NestedSupportingBackend { manifest };
1815 let nested = a_nested_struct();
1816 let columns = vec![AnalyzedColumn {
1817 name: "posts".to_string(),
1818 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1819 nullable: true,
1820 ..Default::default()
1821 }];
1822
1823 let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1824
1825 assert_eq!(
1826 degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
1827 "a supported backend must leave the nested reference untouched"
1828 );
1829 assert_eq!(defs.len(), 1);
1830 assert_eq!(defs[0].name, "get_user_posts_row_posts");
1831 assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
1832 assert!(
1833 degradations.is_empty(),
1834 "a supported struct must not be reported as degraded"
1835 );
1836 }
1837
1838 #[test]
1843 fn test_structural_backends_match_json_array_baseline() {
1844 for backend_name in ["python-asyncpg", "elixir-postgrex", "ruby-pg"] {
1845 let backend = get_backend(backend_name, "postgresql").unwrap();
1846
1847 let baseline = make_query(
1848 "GetUserPosts",
1849 QueryCommand::Many,
1850 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1851 vec![AnalyzedColumn {
1852 name: "posts".to_string(),
1853 neutral_type: "json_array".to_string(),
1854 nullable: true,
1855 ..Default::default()
1856 }],
1857 vec![],
1858 );
1859
1860 let mut nested_query = baseline.clone();
1861 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1862 nested_query.nested_structs = vec![a_nested_struct()];
1863
1864 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1865 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1866
1867 assert_eq!(
1868 baseline_result.row_struct, nested_result.row_struct,
1869 "{backend_name} row struct output must match the JSON array baseline"
1870 );
1871 assert_eq!(
1872 baseline_result.query_fn, nested_result.query_fn,
1873 "{backend_name} query output must match the JSON array baseline"
1874 );
1875 assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1876 }
1877 }
1878
1879 #[test]
1880 fn test_php_backends_keep_native_nested_json_rows() {
1881 for backend_name in ["php-pdo", "php-amphp"] {
1882 let backend = get_backend(backend_name, "postgresql").unwrap();
1883 let baseline = make_query(
1884 "GetUserPosts",
1885 QueryCommand::Many,
1886 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1887 vec![AnalyzedColumn {
1888 name: "posts".to_string(),
1889 neutral_type: "json_array".to_string(),
1890 nullable: true,
1891 ..Default::default()
1892 }],
1893 vec![],
1894 );
1895 let mut nested_query = baseline.clone();
1896 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1897 let mut nested = a_nested_struct();
1898 nested.fields.push(NestedFieldInfo {
1899 name: "high_precision_total".to_string(),
1900 neutral_type: "decimal".to_string(),
1901 nullable: false,
1902 });
1903 nested_query.nested_structs = vec![nested];
1904
1905 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1906 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1907
1908 assert_ne!(
1909 baseline_result.row_struct, nested_result.row_struct,
1910 "{backend_name} must retain its typed nested JSON row instead of degrading"
1911 );
1912 assert_eq!(nested_result.nested_struct_defs.len(), 1);
1913 let nested_def = &nested_result.nested_struct_defs[0].code;
1914 assert!(
1915 nested_def.contains("public float $high_precision_total,")
1916 && nested_def.contains("high_precision_total: (float) $value['high_precision_total']")
1917 && !nested_def.contains("@var string"),
1918 "{backend_name} must expose JSON numeric values as floats instead of pretending the decoded value is an exact string:\n{nested_def}"
1919 );
1920 assert!(
1921 nested_result.degraded_nested_structs.is_empty(),
1922 "{backend_name} must not report native nested JSON support as degraded"
1923 );
1924 }
1925 }
1926
1927 #[test]
1931 fn test_csharp_nested_aggregate_matches_plain_json_baseline() {
1932 let backend = get_backend("csharp-npgsql", "postgresql").unwrap();
1933 let baseline = make_query(
1934 "GetUserPosts",
1935 QueryCommand::Many,
1936 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1937 vec![AnalyzedColumn {
1938 name: "posts".to_string(),
1939 neutral_type: "json".to_string(),
1940 nullable: true,
1941 ..Default::default()
1942 }],
1943 vec![],
1944 );
1945
1946 let mut nested_query = baseline.clone();
1947 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1948 nested_query.nested_structs = vec![a_nested_struct()];
1949
1950 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1951 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1952
1953 assert_eq!(baseline_result.row_struct, nested_result.row_struct);
1954 assert_eq!(baseline_result.query_fn, nested_result.query_fn);
1955 assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1956 assert_eq!(baseline_result.enum_def, nested_result.enum_def);
1957 assert_eq!(baseline_result.nested_struct_defs, nested_result.nested_struct_defs);
1958 assert!(
1959 nested_result
1960 .row_struct
1961 .as_deref()
1962 .is_some_and(|row| row.contains("string? Posts"))
1963 );
1964 assert!(
1965 nested_result
1966 .query_fn
1967 .as_deref()
1968 .is_some_and(|query_fn| query_fn.contains("reader.GetString(0)"))
1969 );
1970 }
1971
1972 #[test]
1978 fn test_enum_inside_array_is_emitted_not_just_referenced() {
1979 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1980 let mut query = make_query(
1981 "GetMany",
1982 QueryCommand::Many,
1983 "SELECT id, many_status FROM t",
1984 vec![
1985 AnalyzedColumn {
1986 name: "id".to_string(),
1987 neutral_type: "int32".to_string(),
1988 nullable: false,
1989 ..Default::default()
1990 },
1991 AnalyzedColumn {
1992 name: "many_status".to_string(),
1993 neutral_type: "array<enum::user_status>".to_string(),
1994 nullable: false,
1995 ..Default::default()
1996 },
1997 ],
1998 vec![],
1999 );
2000 query.enums = vec![EnumInfo {
2001 sql_name: "user_status".to_string(),
2002 values: vec!["active".to_string(), "banned".to_string()],
2003 }];
2004
2005 let result = generate_with_backend(&query, &*backend).unwrap();
2006
2007 let enum_def = result
2008 .enum_def
2009 .expect("an enum reached only through array<enum::...> must still get a definition emitted");
2010 assert!(
2011 enum_def.contains("pub enum UserStatus"),
2012 "expected a UserStatus enum definition; got:\n{enum_def}"
2013 );
2014
2015 let row_struct = result.row_struct.unwrap();
2016 assert!(
2017 row_struct.contains("Vec<UserStatus>"),
2018 "row struct must reference the array of the enum; got:\n{row_struct}"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_composite_inside_array_is_emitted_not_just_referenced() {
2024 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2025
2026 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2027 let mut query = make_query(
2028 "GetManyAddrs",
2029 QueryCommand::Many,
2030 "SELECT id, addrs FROM t",
2031 vec![
2032 AnalyzedColumn {
2033 name: "id".to_string(),
2034 neutral_type: "int32".to_string(),
2035 nullable: false,
2036 ..Default::default()
2037 },
2038 AnalyzedColumn {
2039 name: "addrs".to_string(),
2040 neutral_type: "array<composite::address>".to_string(),
2041 nullable: false,
2042 ..Default::default()
2043 },
2044 ],
2045 vec![],
2046 );
2047 query.composites = vec![CompositeInfo {
2048 sql_name: "address".to_string(),
2049 fields: vec![CompositeFieldInfo {
2050 name: "street".to_string(),
2051 neutral_type: "string".to_string(),
2052 nullable: false,
2053 }],
2054 }];
2055
2056 let result = generate_with_backend(&query, &*backend).unwrap();
2057
2058 let model = result
2059 .model_struct
2060 .expect("a composite reached only through array<composite::...> must still get a definition emitted");
2061 assert!(
2062 model.contains("pub struct Address"),
2063 "expected an Address composite definition; got:\n{model}"
2064 );
2065 }
2066
2067 #[test]
2079 fn test_composite_reachable_only_through_another_composites_field_is_emitted() {
2080 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2081
2082 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2083 let mut query = make_query(
2084 "GetShape",
2085 QueryCommand::One,
2086 "SELECT id, bounds FROM shapes",
2087 vec![
2088 AnalyzedColumn {
2089 name: "id".to_string(),
2090 neutral_type: "int32".to_string(),
2091 nullable: false,
2092 ..Default::default()
2093 },
2094 AnalyzedColumn {
2095 name: "bounds".to_string(),
2096 neutral_type: "composite::outer_shape".to_string(),
2097 nullable: false,
2098 ..Default::default()
2099 },
2100 ],
2101 vec![],
2102 );
2103 query.composites = vec![
2104 CompositeInfo {
2105 sql_name: "outer_shape".to_string(),
2106 fields: vec![CompositeFieldInfo {
2107 name: "origin".to_string(),
2108 neutral_type: "composite::inner_point".to_string(),
2109 nullable: false,
2110 }],
2111 },
2112 CompositeInfo {
2113 sql_name: "inner_point".to_string(),
2114 fields: vec![CompositeFieldInfo {
2115 name: "x".to_string(),
2116 neutral_type: "int32".to_string(),
2117 nullable: false,
2118 }],
2119 },
2120 ];
2121
2122 let result = generate_with_backend(&query, &*backend).unwrap();
2123 let model = result
2124 .model_struct
2125 .expect("outer_shape, selected directly, must get a definition emitted");
2126
2127 assert!(
2128 model.contains("pub struct OuterShape"),
2129 "expected an OuterShape composite definition; got:\n{model}"
2130 );
2131 assert!(
2132 model.contains("pub struct InnerPoint"),
2133 "inner_point, reachable only through outer_shape's own field list, must also get a \
2134 definition emitted; got:\n{model}"
2135 );
2136 }
2137
2138 #[test]
2139 fn file_wide_nested_composite_promotion_uses_nested_capable_definition() {
2140 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2141
2142 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2143 let mut query = make_query(
2144 "RoundTripAddress",
2145 QueryCommand::One,
2146 "SELECT $1::user_address AS address",
2147 vec![AnalyzedColumn {
2148 name: "address".to_string(),
2149 neutral_type: "composite::user_address".to_string(),
2150 nullable: false,
2151 ..Default::default()
2152 }],
2153 Vec::new(),
2154 );
2155 query.composites = vec![CompositeInfo {
2156 sql_name: "user_address".to_string(),
2157 fields: vec![CompositeFieldInfo {
2158 name: "street".to_string(),
2159 neutral_type: "string".to_string(),
2160 nullable: false,
2161 }],
2162 }];
2163 let nested_names = AHashSet::from_iter(["user_address".to_string()]);
2164
2165 let result =
2166 generate_with_backend_and_overrides_and_nested_composites(&query, backend.as_ref(), &[], &nested_names)
2167 .unwrap();
2168 let definition = result.model_struct.unwrap();
2169
2170 assert!(definition.contains("serde::Serialize"), "{definition}");
2171 assert!(definition.contains("serde::Deserialize"), "{definition}");
2172 assert_eq!(definition.matches("pub struct UserAddress").count(), 1);
2173 }
2174
2175 #[test]
2185 fn composites_are_emitted_after_the_composites_their_fields_reference() {
2186 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2187
2188 let composites = vec![
2189 CompositeInfo {
2190 sql_name: "outer_shape".to_string(),
2191 fields: vec![CompositeFieldInfo {
2192 name: "origin".to_string(),
2193 neutral_type: "array<composite::inner_point>".to_string(),
2194 nullable: false,
2195 }],
2196 },
2197 CompositeInfo {
2198 sql_name: "inner_point".to_string(),
2199 fields: vec![CompositeFieldInfo {
2200 name: "unit".to_string(),
2201 neutral_type: "composite::scale".to_string(),
2202 nullable: false,
2203 }],
2204 },
2205 CompositeInfo {
2206 sql_name: "scale".to_string(),
2207 fields: vec![CompositeFieldInfo {
2208 name: "factor".to_string(),
2209 neutral_type: "int32".to_string(),
2210 nullable: false,
2211 }],
2212 },
2213 ];
2214
2215 let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2216 .iter()
2217 .map(|c| c.sql_name.as_str())
2218 .collect();
2219
2220 assert_eq!(ordered, vec!["scale", "inner_point", "outer_shape"]);
2224 }
2225
2226 #[test]
2230 fn composites_in_dependency_order_terminates_on_a_cycle() {
2231 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2232
2233 let composites = vec![
2234 CompositeInfo {
2235 sql_name: "a".to_string(),
2236 fields: vec![CompositeFieldInfo {
2237 name: "b".to_string(),
2238 neutral_type: "composite::b".to_string(),
2239 nullable: false,
2240 }],
2241 },
2242 CompositeInfo {
2243 sql_name: "b".to_string(),
2244 fields: vec![CompositeFieldInfo {
2245 name: "a".to_string(),
2246 neutral_type: "composite::a".to_string(),
2247 nullable: false,
2248 }],
2249 },
2250 ];
2251
2252 let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2253 .iter()
2254 .map(|c| c.sql_name.as_str())
2255 .collect();
2256
2257 assert_eq!(ordered.len(), 2, "every composite must still be emitted exactly once");
2258 assert!(ordered.contains(&"a") && ordered.contains(&"b"));
2259 }
2260
2261 #[test]
2262 fn test_unwrap_containers() {
2263 assert_eq!(unwrap_containers("enum::user_status"), "enum::user_status");
2264 assert_eq!(unwrap_containers("array<enum::user_status>"), "enum::user_status");
2265 assert_eq!(unwrap_containers("array<composite::address>"), "composite::address");
2266 assert_eq!(
2267 unwrap_containers("array<nullable<enum::user_status>>"),
2268 "enum::user_status"
2269 );
2270 assert_eq!(unwrap_containers("nullable<array<int32>>"), "int32");
2271 assert_eq!(unwrap_containers("int32"), "int32");
2272 }
2273
2274 #[test]
2280 fn test_grouped_parent_column_named_children_collides_with_synthesized_field() {
2281 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2282 let parent_cols = vec![
2283 AnalyzedColumn {
2284 name: "id".to_string(),
2285 neutral_type: "int32".to_string(),
2286 nullable: false,
2287 ..Default::default()
2288 },
2289 AnalyzedColumn {
2290 name: "children".to_string(),
2291 neutral_type: "string".to_string(),
2292 nullable: false,
2293 ..Default::default()
2294 },
2295 ];
2296 let child_cols = vec![AnalyzedColumn {
2297 name: "order_id".to_string(),
2298 neutral_type: "int32".to_string(),
2299 nullable: false,
2300 ..Default::default()
2301 }];
2302 let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
2303
2304 let query = AnalyzedQuery::build(|aq| {
2305 aq.name = "GetUsersWithOrders".to_string();
2306 aq.command = QueryCommand::Grouped;
2307 aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
2308 SELECT u.id, u.children, o.id AS order_id\n\
2309 FROM users u\n\
2310 JOIN orders o ON o.user_id = u.id"
2311 .to_string();
2312 aq.columns = all_cols;
2313 aq.params = vec![];
2314 aq.deprecated = None;
2315 aq.source_table = None;
2316 aq.composites = vec![];
2317 aq.enums = vec![];
2318 aq.optional_params = vec![];
2319 aq.group_by = Some(GroupByConfig {
2320 table: "users".to_string(),
2321 key_column: "id".to_string(),
2322 parent_columns: parent_cols,
2323 child_columns: child_cols,
2324 });
2325 aq.custom = vec![];
2326 });
2327
2328 let result = generate_with_backend(&query, &*backend);
2329 let err = result.expect_err(
2330 "a parent column literally named 'children' must be rejected, not silently \
2331 produce two `children` fields on the generated struct",
2332 );
2333 assert_eq!(err.code, ErrorCode::DuplicateAlias);
2334 assert!(
2335 err.message.contains("children"),
2336 "error should name the colliding field, got: {}",
2337 err.message
2338 );
2339 }
2340
2341 fn strip_comments(source: &str) -> String {
2359 let mut out = String::with_capacity(source.len());
2360 let mut i = 0;
2361 while i < source.len() {
2362 let rest = &source[i..];
2363 if rest.starts_with("//") || rest.starts_with('#') {
2364 match rest.find('\n') {
2365 Some(nl) => i += nl,
2366 None => i = source.len(),
2367 }
2368 continue;
2369 }
2370 if rest.starts_with("/*") {
2371 match rest.find("*/") {
2372 Some(end) => i += end + 2,
2373 None => i = source.len(),
2374 }
2375 continue;
2376 }
2377 if let Some(quote) = ["\"\"\"", "'''"].into_iter().find(|q| rest.starts_with(q)) {
2378 match rest[quote.len()..].find(quote) {
2379 Some(end) => i += quote.len() + end + quote.len(),
2380 None => i = source.len(),
2381 }
2382 continue;
2383 }
2384 let ch = rest.chars().next().expect("i < source.len()");
2385 out.push(ch);
2386 i += ch.len_utf8();
2387 }
2388 out
2389 }
2390
2391 fn contains_identifier(haystack: &str, needle: &str) -> bool {
2405 if needle.is_empty() {
2406 return false;
2407 }
2408 let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
2409 let mut start = 0;
2410 while let Some(rel) = haystack[start..].find(needle) {
2411 let idx = start + rel;
2412 let before_ok = haystack[..idx].chars().next_back().is_none_or(|c| !is_ident_char(c));
2413 let after_ok = haystack[idx + needle.len()..]
2414 .chars()
2415 .next()
2416 .is_none_or(|c| !is_ident_char(c));
2417 if before_ok && after_ok {
2418 return true;
2419 }
2420 start = idx + 1;
2421 if start >= haystack.len() {
2422 break;
2423 }
2424 }
2425 false
2426 }
2427
2428 const ALL_BACKEND_NAMES: &[&str] = &[
2431 "rust-sqlx",
2432 "rust-tokio-postgres",
2433 "python-psycopg3",
2434 "python-asyncpg",
2435 "python-aiomysql",
2436 "python-aiosqlite",
2437 "python-duckdb",
2438 "typescript-postgres",
2439 "javascript-postgres",
2440 "typescript-pg",
2441 "javascript-pg",
2442 "typescript-mysql2",
2443 "javascript-mysql2",
2444 "typescript-better-sqlite3",
2445 "javascript-better-sqlite3",
2446 "typescript-duckdb",
2447 "javascript-duckdb",
2448 "typescript-node-sqlite",
2449 "javascript-node-sqlite",
2450 "typescript-wasm-sqlite",
2451 "javascript-wasm-sqlite",
2452 "typescript-kysely",
2453 "go-database-sql",
2454 "go-pgx",
2455 "java-jdbc",
2456 "java-r2dbc",
2457 "kotlin-exposed",
2458 "kotlin-jdbc",
2459 "kotlin-r2dbc",
2460 "csharp-npgsql",
2461 "csharp-mysqlconnector",
2462 "csharp-microsoft-sqlite",
2463 "elixir-postgrex",
2464 "elixir-ecto",
2465 "elixir-myxql",
2466 "elixir-exqlite",
2467 "ruby-pg",
2468 "ruby-mysql2",
2469 "ruby-sqlite3",
2470 "ruby-trilogy",
2471 "php-pdo",
2472 "php-amphp",
2473 "rust-tiberius",
2474 "python-pyodbc",
2475 "typescript-mssql",
2476 "javascript-mssql",
2477 "csharp-sqlclient",
2478 "ruby-tiny-tds",
2479 "elixir-tds",
2480 "rust-sibyl",
2481 "python-oracledb",
2482 "typescript-oracledb",
2483 "javascript-oracledb",
2484 "go-godror",
2485 "csharp-oracle",
2486 "ruby-oci8",
2487 "elixir-jamdb",
2488 "python-snowflake",
2489 "typescript-snowflake",
2490 "javascript-snowflake",
2491 "go-gosnowflake",
2492 "csharp-snowflake",
2493 ];
2494
2495 fn backend_for_select_star(name: &str) -> Box<dyn CodegenBackend> {
2501 const ENGINES: &[&str] = &[
2502 "postgresql",
2503 "mysql",
2504 "sqlite",
2505 "mssql",
2506 "mariadb",
2507 "duckdb",
2508 "oracle",
2509 "snowflake",
2510 "redshift",
2511 ];
2512 for engine in ENGINES {
2513 if let Ok(backend) = get_backend(name, engine) {
2514 return backend;
2515 }
2516 }
2517 panic!("no known engine works for backend '{name}'");
2518 }
2519
2520 fn select_star_query() -> AnalyzedQuery {
2521 let mut query = make_query(
2522 "GetAllUsers",
2523 QueryCommand::Many,
2524 "SELECT * FROM users",
2525 vec![
2526 AnalyzedColumn {
2527 name: "id".to_string(),
2528 neutral_type: "int32".to_string(),
2529 nullable: false,
2530 ..Default::default()
2531 },
2532 AnalyzedColumn {
2533 name: "name".to_string(),
2534 neutral_type: "string".to_string(),
2535 nullable: false,
2536 ..Default::default()
2537 },
2538 ],
2539 vec![],
2540 );
2541 query.source_table = Some("users".to_string());
2542 query
2543 }
2544
2545 struct BackendNote {
2549 backend: &'static str,
2550 reason: &'static str,
2551 }
2552
2553 const KNOWN_DIVERGENT_BACKENDS: &[BackendNote] = &[];
2584
2585 const NOT_APPLICABLE_BACKENDS: &[BackendNote] = &[
2603 BackendNote {
2604 backend: "javascript-postgres",
2605 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2606 the check to find once comments are stripped",
2607 },
2608 BackendNote {
2609 backend: "javascript-pg",
2610 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2611 the check to find once comments are stripped",
2612 },
2613 BackendNote {
2614 backend: "javascript-mysql2",
2615 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2616 the check to find once comments are stripped",
2617 },
2618 BackendNote {
2619 backend: "javascript-better-sqlite3",
2620 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2621 the check to find once comments are stripped",
2622 },
2623 BackendNote {
2624 backend: "javascript-node-sqlite",
2625 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2626 the check to find once comments are stripped",
2627 },
2628 BackendNote {
2629 backend: "javascript-wasm-sqlite",
2630 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2631 the check to find once comments are stripped",
2632 },
2633 BackendNote {
2634 backend: "javascript-snowflake",
2635 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2636 the check to find once comments are stripped",
2637 },
2638 BackendNote {
2639 backend: "javascript-duckdb",
2640 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2641 the check to find once comments are stripped",
2642 },
2643 BackendNote {
2644 backend: "javascript-oracledb",
2645 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2646 the check to find once comments are stripped",
2647 },
2648 BackendNote {
2649 backend: "javascript-mssql",
2650 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2651 the check to find once comments are stripped",
2652 },
2653 ];
2654
2655 #[test]
2663 fn test_select_star_declares_and_references_the_same_struct_name_across_all_backends() {
2664 let query = select_star_query();
2665
2666 let mut regressions = Vec::new();
2667 let mut stale_entries = Vec::new();
2668 let mut seen_not_applicable: Vec<&str> = Vec::new();
2669
2670 for &name in ALL_BACKEND_NAMES {
2671 if let Some(note) = NOT_APPLICABLE_BACKENDS.iter().find(|n| n.backend == name) {
2672 seen_not_applicable.push(note.backend);
2673 continue;
2674 }
2675
2676 let backend = backend_for_select_star(name);
2677 let struct_name = determine_struct_name(&query, backend.manifest());
2678
2679 let agrees = match generate_with_backend(&query, &*backend) {
2680 Err(_) => false,
2681 Ok(result) => match (result.model_struct, result.query_fn) {
2682 (Some(model_struct), Some(query_fn)) => {
2683 let declared = contains_identifier(&strip_comments(&model_struct), &struct_name);
2684 let referenced = contains_identifier(&strip_comments(&query_fn), &struct_name);
2685 declared && referenced
2686 }
2687 _ => false,
2688 },
2689 };
2690
2691 let listed = KNOWN_DIVERGENT_BACKENDS.iter().find(|n| n.backend == name);
2692 match (agrees, listed) {
2693 (true, Some(_)) => stale_entries.push(name),
2694 (false, None) => regressions.push(name),
2695 (false, Some(note)) => eprintln!("expected divergence, {name}: {}", note.reason),
2699 (true, None) => {}
2700 }
2701 }
2702
2703 let unknown_not_applicable: Vec<&str> = NOT_APPLICABLE_BACKENDS
2709 .iter()
2710 .map(|n| n.backend)
2711 .filter(|b| !seen_not_applicable.contains(b))
2712 .collect();
2713
2714 let mut failures = Vec::new();
2715 for name in ®ressions {
2716 let reason = "diverges on SELECT * struct naming and is not in KNOWN_DIVERGENT_BACKENDS";
2717 failures.push(format!(
2718 "REGRESSION: {name}: {reason} -- investigate before adding a line"
2719 ));
2720 }
2721 for name in &stale_entries {
2722 failures.push(format!(
2723 "STALE ALLOWLIST: {name}: listed in KNOWN_DIVERGENT_BACKENDS but now agrees -- delete its entry"
2724 ));
2725 }
2726 for name in &unknown_not_applicable {
2727 failures.push(format!(
2728 "STALE ALLOWLIST: {name}: listed in NOT_APPLICABLE_BACKENDS but is not in ALL_BACKEND_NAMES"
2729 ));
2730 }
2731
2732 assert!(
2733 failures.is_empty(),
2734 "{} of {} backends need attention:\n{}",
2735 failures.len(),
2736 ALL_BACKEND_NAMES.len(),
2737 failures.join("\n")
2738 );
2739 }
2740}