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 scythe_backend::manifest::BackendManifest;
17use scythe_backend::naming::{NamingConfig, apply_case, row_struct_name, sanitize_for_identifier, to_pascal_case};
18
19use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, CompositeInfo, EnumInfo, NestedStructInfo};
20use scythe_core::errors::{ErrorCode, ScytheError};
21use scythe_core::parser::QueryCommand;
22
23const GROUPED_CHILDREN_FIELD: &str = "children";
29
30#[derive(Debug, Clone, Default)]
31#[non_exhaustive]
32pub struct GeneratedCode {
33 pub query_fn: Option<String>,
34 pub row_struct: Option<String>,
35 pub model_struct: Option<String>,
36 pub enum_def: Option<String>,
37 pub nested_struct_defs: Vec<NestedStructDef>,
48 pub degraded_nested_structs: Vec<NestedStructDegradation>,
74}
75
76impl GeneratedCode {
77 #[must_use]
97 pub fn build(init: impl FnOnce(&mut Self)) -> Self {
98 let mut code = Self::default();
99 init(&mut code);
100 code
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct NestedStructDef {
108 pub name: String,
111 pub code: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct NestedStructDegradation {
122 pub column: String,
125 pub struct_name: String,
129 pub fallback_type: &'static str,
133 pub backend: String,
136}
137
138pub fn singularize(name: &str) -> String {
140 if let Some(stem) = name.strip_suffix("ies") {
141 format!("{stem}y")
142 } else if name.ends_with("sses")
143 || name.ends_with("shes")
144 || name.ends_with("ches")
145 || name.ends_with("xes")
146 || name.ends_with("zes")
147 || name.ends_with("ses")
148 {
149 name[..name.len() - 2].to_string()
150 } else if name.ends_with('s') && !name.ends_with("ss") {
151 name[..name.len() - 1].to_string()
152 } else {
153 name.to_string()
154 }
155}
156
157pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
159 let backend = get_backend(backend_name, "postgresql")?;
160 Ok(backend.manifest().clone())
161}
162
163pub fn model_struct_name(table_name: &str, naming: &NamingConfig) -> String {
187 let sanitized = sanitize_for_identifier(table_name);
188 apply_case(&singularize(&sanitized), &naming.struct_case).into_owned()
189}
190
191fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
193 if let Some(ref table_name) = analyzed.source_table {
194 model_struct_name(table_name, &manifest.naming)
195 } else {
196 row_struct_name(&analyzed.name, &manifest.naming)
197 }
198}
199
200pub fn generate_with_backend(
202 analyzed: &AnalyzedQuery,
203 backend: &dyn CodegenBackend,
204) -> Result<GeneratedCode, ScytheError> {
205 generate_with_backend_and_overrides(analyzed, backend, &[])
206}
207
208pub fn generate_with_backend_and_overrides(
210 analyzed: &AnalyzedQuery,
211 backend: &dyn CodegenBackend,
212 overrides: &[TypeOverride],
213) -> Result<GeneratedCode, ScytheError> {
214 let manifest = backend.manifest();
215 let source_table = analyzed.source_table.as_deref().unwrap_or("");
216
217 let (degraded_columns, nested_struct_defs, nested_degradations) = if analyzed.nested_structs.is_empty() {
230 (None, Vec::new(), Vec::new())
231 } else {
232 let (cols, defs, degradations) =
233 degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
234 (Some(cols), defs, degradations)
235 };
236 let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
237
238 let columns = resolve::resolve_columns(
239 degraded_columns.as_deref().unwrap_or(&analyzed.columns),
240 manifest,
241 overrides,
242 )?;
243 let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
244
245 let mut result = GeneratedCode::default();
246
247 let struct_name = determine_struct_name(analyzed, manifest);
252 let needs_row_struct = matches!(
253 analyzed.command,
254 QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
255 );
256 let declared_struct_name = (needs_row_struct && !analyzed.columns.is_empty()).then_some(struct_name.as_str());
261
262 let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs, declared_struct_name)?;
263 if !enum_def.is_empty() {
264 result.enum_def = Some(enum_def);
265 }
266
267 if needs_row_struct && !analyzed.columns.is_empty() {
268 if let Some(ref table_name) = analyzed.source_table {
269 result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
270 } else {
271 result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
272 }
273 }
274
275 let mut extra_defs = String::new();
276 let reachable_composites = reachable_composite_names(analyzed, &nested_refs);
277 for comp in composites_in_dependency_order(&analyzed.composites) {
278 if !reachable_composites.contains(comp.sql_name.as_str()) {
288 continue;
289 }
290 let from_nested = nested_refs.composites.contains(comp.sql_name.as_str());
291 if !extra_defs.is_empty() {
292 extra_defs.push_str("\n\n");
293 }
294 if from_nested {
295 extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
296 } else {
297 extra_defs.push_str(&backend.generate_composite_def(comp)?);
298 }
299 }
300 if !extra_defs.is_empty() {
301 if let Some(ref mut existing) = result.model_struct {
302 existing.push_str("\n\n");
303 existing.push_str(&extra_defs);
304 } else {
305 result.model_struct = Some(extra_defs);
306 }
307 }
308 result.nested_struct_defs = nested_struct_defs;
309 result.degraded_nested_structs = nested_degradations;
310
311 if analyzed.command == QueryCommand::Grouped {
312 let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
313 ScytheError::new(
314 ErrorCode::InternalError,
315 format!(
316 "query '{}' is :grouped but is missing @group_by annotation",
317 analyzed.name
318 ),
319 )
320 })?;
321
322 let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
323 let child_struct_name = {
324 let suffix = &manifest.naming.row_suffix;
325 let base = parent_struct_name.trim_end_matches(suffix.as_str());
326 format!("{}Child{}", base, suffix)
327 };
328
329 let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
335 (None, None)
336 } else {
337 let (dp, _, parent_degradations) =
338 degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
339 let (dc, _, child_degradations) =
340 degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
341 result.degraded_nested_structs.extend(parent_degradations);
342 result.degraded_nested_structs.extend(child_degradations);
343 (Some(dp), Some(dc))
344 };
345 let parent_cols = resolve::resolve_columns(
346 degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
347 manifest,
348 overrides,
349 )?;
350 let child_cols = resolve::resolve_columns(
351 degraded_child.as_deref().unwrap_or(&group_by.child_columns),
352 manifest,
353 overrides,
354 )?;
355
356 let children_field_name = scythe_backend::naming::field_name(GROUPED_CHILDREN_FIELD, &manifest.naming);
368 resolve::check_field_name_collisions(
369 parent_cols
370 .iter()
371 .map(|c| (c.name.as_str(), c.field_name.as_str()))
372 .chain(std::iter::once((
373 "<synthesized :grouped children field>",
374 children_field_name.as_ref(),
375 ))),
376 "parent columns",
377 &manifest.naming,
378 )?;
379
380 result.row_struct = Some(backend.generate_grouped_structs(
381 &parent_struct_name,
382 &child_struct_name,
383 &parent_cols,
384 &child_cols,
385 &group_by.key_column,
386 )?);
387 result.query_fn = Some(
388 backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
389 analyzed,
390 parent_struct_name: &parent_struct_name,
391 child_struct_name: &child_struct_name,
392 all_columns: &columns,
393 parent_columns: &parent_cols,
394 child_columns: &child_cols,
395 params: ¶ms,
396 key_column: &group_by.key_column,
397 })?,
398 );
399 } else {
400 result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
401 }
402
403 Ok(result)
404}
405
406#[derive(Debug, Default)]
422struct NestedTypeRefs {
423 enums: ahash::AHashSet<String>,
424 composites: ahash::AHashSet<String>,
425}
426
427impl NestedTypeRefs {
428 fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
429 let mut refs = Self::default();
430 for nested in &analyzed.nested_structs {
431 if !supported.iter().any(|def| def.name == nested.name) {
432 continue;
433 }
434 for field in &nested.fields {
435 let unwrapped = unwrap_containers(&field.neutral_type);
436 if let Some(name) = unwrapped.strip_prefix("enum::") {
437 refs.enums.insert(name.to_string());
438 } else if let Some(name) = unwrapped.strip_prefix("composite::") {
439 refs.composites.insert(name.to_string());
440 }
441 }
442 }
443 refs
444 }
445}
446
447fn composites_in_dependency_order(composites: &[CompositeInfo]) -> Vec<&CompositeInfo> {
472 let by_name: ahash::AHashMap<&str, &CompositeInfo> = composites.iter().map(|c| (c.sql_name.as_str(), c)).collect();
473 let mut ordered: Vec<&CompositeInfo> = Vec::with_capacity(composites.len());
474 let mut emitted: ahash::AHashSet<&str> = ahash::AHashSet::new();
475 let mut visiting: ahash::AHashSet<&str> = ahash::AHashSet::new();
476
477 fn visit<'a>(
478 comp: &'a CompositeInfo,
479 by_name: &ahash::AHashMap<&'a str, &'a CompositeInfo>,
480 emitted: &mut ahash::AHashSet<&'a str>,
481 visiting: &mut ahash::AHashSet<&'a str>,
482 ordered: &mut Vec<&'a CompositeInfo>,
483 ) {
484 let name = comp.sql_name.as_str();
485 if emitted.contains(name) || !visiting.insert(name) {
486 return;
487 }
488 for field in &comp.fields {
489 if let Some(dep) = unwrap_containers(&field.neutral_type).strip_prefix("composite::")
490 && let Some(dep) = by_name.get(dep)
491 {
492 visit(dep, by_name, emitted, visiting, ordered);
493 }
494 }
495 visiting.remove(name);
496 if emitted.insert(name) {
497 ordered.push(comp);
498 }
499 }
500
501 for comp in composites {
502 visit(comp, &by_name, &mut emitted, &mut visiting, &mut ordered);
503 }
504 ordered
505}
506
507pub(crate) fn unwrap_containers(neutral: &str) -> &str {
525 let mut current = neutral;
526 loop {
527 if let Some(inner) = current.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
528 current = inner.trim();
529 } else if let Some(inner) = current.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
530 current = inner.trim();
531 } else {
532 return current;
533 }
534 }
535}
536
537fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
545 let neutral = format!("{prefix}{sql_name}");
546 analyzed
547 .columns
548 .iter()
549 .any(|col| unwrap_containers(&col.neutral_type) == neutral)
550 || analyzed
551 .params
552 .iter()
553 .any(|param| unwrap_containers(¶m.neutral_type) == neutral)
554}
555
556fn reachable_composite_names<'a>(
571 analyzed: &'a AnalyzedQuery,
572 nested_refs: &NestedTypeRefs,
573) -> ahash::AHashSet<&'a str> {
574 let mut reachable: ahash::AHashSet<&str> = ahash::AHashSet::new();
575 let mut worklist: Vec<&str> = analyzed
576 .composites
577 .iter()
578 .filter(|comp| {
579 nested_refs.composites.contains(comp.sql_name.as_str())
580 || type_referenced_by_columns(analyzed, "composite::", &comp.sql_name)
581 })
582 .map(|comp| comp.sql_name.as_str())
583 .collect();
584
585 while let Some(name) = worklist.pop() {
586 if !reachable.insert(name) {
587 continue;
588 }
589 let Some(comp) = analyzed.composites.iter().find(|c| c.sql_name == name) else {
590 continue;
591 };
592 for field in &comp.fields {
593 if let Some(nested_name) = unwrap_containers(&field.neutral_type).strip_prefix("composite::") {
594 worklist.push(nested_name);
595 }
596 }
597 }
598 reachable
599}
600
601pub fn nested_type_is_emitted(
608 analyzed: &AnalyzedQuery,
609 supported: &[NestedStructDef],
610 prefix: &str,
611 sql_name: &str,
612) -> bool {
613 let neutral = format!("{prefix}{sql_name}");
614 analyzed
615 .nested_structs
616 .iter()
617 .filter(|nested| supported.iter().any(|def| def.name == nested.name))
618 .any(|nested| {
619 nested
620 .fields
621 .iter()
622 .any(|field| unwrap_containers(&field.neutral_type) == neutral)
623 })
624}
625
626fn generate_enum_defs_via_backend(
634 analyzed: &AnalyzedQuery,
635 backend: &dyn CodegenBackend,
636 nested_refs: &NestedTypeRefs,
637 declared_struct_name: Option<&str>,
638) -> Result<String, ScytheError> {
639 use ahash::AHashSet;
640 use std::fmt::Write;
641
642 let naming = &backend.manifest().naming;
643
644 let enum_sources: Vec<&str> = analyzed
645 .columns
646 .iter()
647 .filter_map(|col| unwrap_containers(&col.neutral_type).strip_prefix("enum::"))
648 .chain(
649 analyzed
650 .params
651 .iter()
652 .filter_map(|p| unwrap_containers(&p.neutral_type).strip_prefix("enum::")),
653 )
654 .chain(nested_refs.enums.iter().map(String::as_str))
655 .collect();
656
657 let mut seen_enums: AHashSet<&str> = AHashSet::new();
658 let unique_sources: Vec<&str> = enum_sources
659 .into_iter()
660 .filter(|name| seen_enums.insert(*name))
661 .collect();
662
663 let generated_names: Vec<(&str, String)> = unique_sources
669 .iter()
670 .map(|sql_name| (*sql_name, scythe_backend::naming::enum_type_name(sql_name, naming)))
671 .collect();
672 let query_type_entry = declared_struct_name.map(|name| ("<query row/model type>", name.to_string()));
673 resolve::check_type_name_collisions(
674 generated_names
675 .iter()
676 .chain(query_type_entry.iter())
677 .map(|(source, name)| (*source, name.as_str())),
678 "enum and query type names",
679 )?;
680
681 let mut out = String::new();
682 for sql_name in unique_sources {
683 if !out.is_empty() {
684 let _ = writeln!(out);
685 }
686
687 let stub_info;
688 let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
689 Some(info) => info,
690 None => {
691 stub_info = EnumInfo {
692 sql_name: sql_name.to_string(),
693 values: vec![],
694 };
695 &stub_info
696 }
697 };
698 let variant_names: Vec<(&str, String)> = enum_info
703 .values
704 .iter()
705 .map(|value| (value.as_str(), scythe_backend::naming::enum_variant_name(value, naming)))
706 .collect();
707 resolve::check_enum_variant_collisions(
708 variant_names.iter().map(|(source, name)| (*source, name.as_str())),
709 sql_name,
710 )?;
711
712 if nested_refs.enums.contains(sql_name) {
713 out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
714 } else {
715 out.push_str(&backend.generate_enum_def(enum_info)?);
716 }
717 }
718
719 Ok(out)
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub(crate) struct NestedColumnShape<'a> {
725 pub(crate) is_array: bool,
727 pub(crate) element_nullable: bool,
731 pub(crate) name: &'a str,
733}
734
735pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
754 let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
755 let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
756 return Some(NestedColumnShape {
757 is_array: false,
758 element_nullable: false,
759 name: inner,
760 });
761 };
762 match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
763 Some(name) => Some(NestedColumnShape {
764 is_array: true,
765 element_nullable: true,
766 name,
767 }),
768 None => Some(NestedColumnShape {
769 is_array: true,
770 element_nullable: false,
771 name: element,
772 }),
773 }
774}
775
776pub type DegradedNestedStructs = (Vec<AnalyzedColumn>, Vec<NestedStructDef>, Vec<NestedStructDegradation>);
816
817pub fn degrade_unsupported_nested_structs(
818 columns: &[AnalyzedColumn],
819 nested_structs: &[NestedStructInfo],
820 backend: &dyn CodegenBackend,
821) -> Result<DegradedNestedStructs, ScytheError> {
822 if nested_structs.is_empty() {
823 return Ok((columns.to_vec(), Vec::new(), Vec::new()));
824 }
825
826 use ahash::AHashSet;
827
828 let mut unsupported: AHashSet<String> = AHashSet::new();
829 let mut defs: Vec<NestedStructDef> = Vec::new();
830 for nested in nested_structs {
831 match backend.generate_nested_struct_def(nested)? {
832 Some(def) => defs.push(NestedStructDef {
833 name: nested.name.clone(),
834 code: def,
835 }),
836 None => {
837 unsupported.insert(to_pascal_case(&nested.name).into_owned());
838 }
839 }
840 }
841
842 if unsupported.is_empty() {
843 return Ok((columns.to_vec(), defs, Vec::new()));
844 }
845
846 let supports_json_array = backend.manifest().types.scalars.contains_key("json_array");
847 let backend_name = backend.name().to_string();
848
849 let mut degradations: Vec<NestedStructDegradation> = Vec::new();
850 let degraded = columns
851 .iter()
852 .cloned()
853 .map(|mut col| {
854 if let Some(shape) = nested_struct_shape(&col.neutral_type)
855 && unsupported.contains(shape.name)
856 {
857 let fallback_type = if shape.is_array && supports_json_array {
858 "json_array"
859 } else {
860 "json"
861 };
862 degradations.push(NestedStructDegradation {
863 column: col.name.clone(),
864 struct_name: shape.name.to_string(),
865 fallback_type,
866 backend: backend_name.clone(),
867 });
868 col.neutral_type = fallback_type.to_string();
869 }
870 col
871 })
872 .collect();
873
874 Ok((degraded, defs, degradations))
875}
876
877pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
879 let backend = get_backend("rust-sqlx", "postgresql")?;
880 generate_with_backend(analyzed, &*backend)
881}
882
883pub fn generate_single_enum_def_with_backend(
892 enum_info: &EnumInfo,
893 backend: &dyn CodegenBackend,
894 reachable_from_nested: bool,
895) -> Result<String, ScytheError> {
896 if reachable_from_nested {
897 backend.generate_enum_def_for_nested(enum_info)
898 } else {
899 backend.generate_enum_def(enum_info)
900 }
901}
902
903pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
906 use scythe_backend::naming::{enum_type_name, enum_variant_name};
907 use std::fmt::Write;
908
909 let mut out = String::with_capacity(256);
910 let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
911
912 let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
913 let _ = writeln!(
914 out,
915 "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
916 enum_info.sql_name
917 );
918 let _ = writeln!(out, "pub enum {type_name} {{");
919
920 for value in &enum_info.values {
921 let variant = enum_variant_name(value, &manifest.naming);
922 let _ = writeln!(out, " {variant},");
923 }
924
925 let _ = write!(out, "}}");
926 out
927}
928
929pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
931 let b = backends::sqlx::SqlxBackend::new("postgresql")?;
932 Ok(b.manifest().clone())
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
939 use scythe_core::parser::QueryCommand;
940
941 fn make_query(
942 name: &str,
943 command: QueryCommand,
944 sql: &str,
945 columns: Vec<AnalyzedColumn>,
946 params: Vec<AnalyzedParam>,
947 ) -> AnalyzedQuery {
948 AnalyzedQuery::build(|aq| {
949 aq.name = name.to_string();
950 aq.command = command;
951 aq.sql = sql.to_string();
952 aq.columns = columns;
953 aq.params = params;
954 aq.deprecated = None;
955 aq.source_table = None;
956 aq.composites = Vec::new();
957 aq.enums = Vec::new();
958 aq.optional_params = Vec::new();
959 aq.group_by = None;
960 aq.custom = Vec::new();
961 })
962 }
963
964 fn test_naming() -> NamingConfig {
965 NamingConfig {
966 struct_case: "PascalCase".to_string(),
967 fn_case: "snake_case".to_string(),
968 enum_variant_case: "PascalCase".to_string(),
969 row_suffix: "Row".to_string(),
970 field_case: "snake_case".to_string(),
971 reserved: Vec::new(),
972 reserved_bindings: Vec::new(),
973 sanitize_field_names: false,
974 }
975 }
976
977 #[test]
985 fn model_struct_name_sanitizes_a_schema_qualified_dot() {
986 assert_eq!(model_struct_name("app.widgets", &test_naming()), "AppWidget");
987 }
988
989 #[test]
990 fn test_generate_select_many() {
991 let query = make_query(
992 "ListUsers",
993 QueryCommand::Many,
994 "SELECT id, name, email FROM users",
995 vec![
996 AnalyzedColumn {
997 name: "id".to_string(),
998 neutral_type: "int32".to_string(),
999 nullable: false,
1000 ..Default::default()
1001 },
1002 AnalyzedColumn {
1003 name: "name".to_string(),
1004 neutral_type: "string".to_string(),
1005 nullable: false,
1006 ..Default::default()
1007 },
1008 AnalyzedColumn {
1009 name: "email".to_string(),
1010 neutral_type: "string".to_string(),
1011 nullable: true,
1012 ..Default::default()
1013 },
1014 ],
1015 vec![],
1016 );
1017
1018 let result = generate(&query).unwrap();
1019
1020 let row_struct = result.row_struct.unwrap();
1021 assert!(row_struct.contains("pub struct ListUsersRow"));
1022 assert!(row_struct.contains("pub id: i32"));
1023 assert!(row_struct.contains("pub name: String"));
1024 assert!(row_struct.contains("pub email: Option<String>"));
1025
1026 let query_fn = result.query_fn.unwrap();
1027 assert!(query_fn.contains("pub async fn list_users("));
1028 assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
1029 assert!(query_fn.contains(".fetch_all(pool)"));
1030 }
1031
1032 #[test]
1033 fn test_generate_select_one_with_param() {
1034 let query = make_query(
1035 "GetUser",
1036 QueryCommand::One,
1037 "SELECT id, name FROM users WHERE id = $1",
1038 vec![
1039 AnalyzedColumn {
1040 name: "id".to_string(),
1041 neutral_type: "int32".to_string(),
1042 nullable: false,
1043 ..Default::default()
1044 },
1045 AnalyzedColumn {
1046 name: "name".to_string(),
1047 neutral_type: "string".to_string(),
1048 nullable: false,
1049 ..Default::default()
1050 },
1051 ],
1052 vec![AnalyzedParam {
1053 name: "id".to_string(),
1054 neutral_type: "int32".to_string(),
1055 nullable: false,
1056 position: 1,
1057 source_relation: None,
1058 }],
1059 );
1060
1061 let result = generate(&query).unwrap();
1062
1063 let query_fn = result.query_fn.unwrap();
1064 assert!(query_fn.contains("pub async fn get_user("));
1065 assert!(query_fn.contains("id: i32"));
1066 assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
1067 assert!(query_fn.contains(".fetch_one(pool)"));
1068 }
1069
1070 #[test]
1071 fn test_generate_exec() {
1072 let query = make_query(
1073 "DeleteUser",
1074 QueryCommand::Exec,
1075 "DELETE FROM users WHERE id = $1",
1076 vec![],
1077 vec![AnalyzedParam {
1078 name: "id".to_string(),
1079 neutral_type: "int32".to_string(),
1080 nullable: false,
1081 position: 1,
1082 source_relation: None,
1083 }],
1084 );
1085
1086 let result = generate(&query).unwrap();
1087
1088 assert!(result.row_struct.is_none());
1089
1090 let query_fn = result.query_fn.unwrap();
1091 assert!(query_fn.contains("pub async fn delete_user("));
1092 assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
1093 assert!(query_fn.contains(".execute(pool)"));
1094 }
1095
1096 #[test]
1097 fn test_generate_with_enum_column() {
1098 let query = make_query(
1099 "GetUserStatus",
1100 QueryCommand::One,
1101 "SELECT id, status FROM users WHERE id = $1",
1102 vec![
1103 AnalyzedColumn {
1104 name: "id".to_string(),
1105 neutral_type: "int32".to_string(),
1106 nullable: false,
1107 ..Default::default()
1108 },
1109 AnalyzedColumn {
1110 name: "status".to_string(),
1111 neutral_type: "enum::user_status".to_string(),
1112 nullable: false,
1113 ..Default::default()
1114 },
1115 ],
1116 vec![AnalyzedParam {
1117 name: "id".to_string(),
1118 neutral_type: "int32".to_string(),
1119 nullable: false,
1120 position: 1,
1121 source_relation: None,
1122 }],
1123 );
1124
1125 let result = generate(&query).unwrap();
1126
1127 assert!(result.enum_def.is_some());
1128 let enum_def = result.enum_def.unwrap();
1129 assert!(enum_def.contains("pub enum UserStatus"));
1130 assert!(enum_def.contains("type_name = \"user_status\""));
1131
1132 let row_struct = result.row_struct.unwrap();
1133 assert!(row_struct.contains("pub status: UserStatus"));
1134 }
1135
1136 #[test]
1137 fn test_singularize_basic() {
1138 assert_eq!(singularize("users"), "user");
1139 assert_eq!(singularize("orders"), "order");
1140 assert_eq!(singularize("posts"), "post");
1141 }
1142
1143 #[test]
1144 fn test_singularize_ies() {
1145 assert_eq!(singularize("categories"), "category");
1146 assert_eq!(singularize("entries"), "entry");
1147 }
1148
1149 #[test]
1150 fn test_singularize_sses() {
1151 assert_eq!(singularize("addresses"), "address");
1152 assert_eq!(singularize("classes"), "class");
1153 }
1154
1155 #[test]
1156 fn test_singularize_no_change() {
1157 assert_eq!(singularize("status"), "statu");
1158 assert_eq!(singularize("boss"), "boss");
1159 assert_eq!(singularize("address"), "address");
1160 }
1161
1162 #[test]
1163 fn test_singularize_shes_ches_xes() {
1164 assert_eq!(singularize("batches"), "batch");
1165 assert_eq!(singularize("boxes"), "box");
1166 assert_eq!(singularize("wishes"), "wish");
1167 }
1168
1169 fn make_grouped_query() -> AnalyzedQuery {
1170 let parent_cols = vec![
1171 AnalyzedColumn {
1172 name: "id".to_string(),
1173 neutral_type: "int32".to_string(),
1174 nullable: false,
1175 ..Default::default()
1176 },
1177 AnalyzedColumn {
1178 name: "name".to_string(),
1179 neutral_type: "string".to_string(),
1180 nullable: false,
1181 ..Default::default()
1182 },
1183 AnalyzedColumn {
1184 name: "email".to_string(),
1185 neutral_type: "string".to_string(),
1186 nullable: false,
1187 ..Default::default()
1188 },
1189 ];
1190 let child_cols = vec![
1191 AnalyzedColumn {
1192 name: "order_id".to_string(),
1193 neutral_type: "int32".to_string(),
1194 nullable: false,
1195 ..Default::default()
1196 },
1197 AnalyzedColumn {
1198 name: "total".to_string(),
1199 neutral_type: "decimal".to_string(),
1200 nullable: true,
1201 ..Default::default()
1202 },
1203 AnalyzedColumn {
1204 name: "order_date".to_string(),
1205 neutral_type: "datetime".to_string(),
1206 nullable: false,
1207 ..Default::default()
1208 },
1209 ];
1210 let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
1211
1212 AnalyzedQuery::build(|aq| {
1213 aq.name = "GetUsersWithOrders".to_string();
1214 aq.command = QueryCommand::Grouped;
1215 aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
1216 SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
1217 FROM users u\n\
1218 JOIN orders o ON o.user_id = u.id"
1219 .to_string();
1220 aq.columns = all_cols;
1221 aq.params = vec![];
1222 aq.deprecated = None;
1223 aq.source_table = None;
1224 aq.composites = vec![];
1225 aq.enums = vec![];
1226 aq.optional_params = vec![];
1227 aq.group_by = Some(GroupByConfig {
1228 table: "users".to_string(),
1229 key_column: "id".to_string(),
1230 parent_columns: parent_cols,
1231 child_columns: child_cols,
1232 });
1233 aq.custom = vec![];
1234 })
1235 }
1236
1237 #[test]
1238 fn test_grouped_sqlx_structs() {
1239 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1240 let query = make_grouped_query();
1241 let result = generate_with_backend(&query, &*backend).unwrap();
1242
1243 let row_struct = result.row_struct.as_deref().unwrap();
1244 assert!(
1245 row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
1246 "missing child struct; got:\n{row_struct}"
1247 );
1248 assert!(
1249 row_struct.contains("pub order_id: i32"),
1250 "child struct missing order_id field; got:\n{row_struct}"
1251 );
1252 assert!(
1253 row_struct.contains("pub struct GetUsersWithOrdersRow"),
1254 "missing parent struct; got:\n{row_struct}"
1255 );
1256 assert!(
1257 row_struct.contains("pub id: i32"),
1258 "parent struct missing id field; got:\n{row_struct}"
1259 );
1260 assert!(
1261 row_struct.contains("pub name: String"),
1262 "parent struct missing name field; got:\n{row_struct}"
1263 );
1264 assert!(
1265 row_struct.contains("pub email: String"),
1266 "parent struct missing email field; got:\n{row_struct}"
1267 );
1268 assert!(
1269 row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
1270 "parent struct missing children field; got:\n{row_struct}"
1271 );
1272 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1273 let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
1274 assert!(
1275 child_pos < parent_pos,
1276 "child struct must be defined before parent struct"
1277 );
1278
1279 assert!(
1280 result.model_struct.is_none(),
1281 "grouped should not produce a model_struct"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_grouped_sqlx_query_fn() {
1287 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1288 let query = make_grouped_query();
1289 let result = generate_with_backend(&query, &*backend).unwrap();
1290
1291 let query_fn = result.query_fn.as_deref().unwrap();
1292 assert!(
1293 query_fn.contains("pub async fn get_users_with_orders("),
1294 "missing fn; got:\n{query_fn}"
1295 );
1296 assert!(
1297 query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
1298 "wrong return type; got:\n{query_fn}"
1299 );
1300 assert!(
1301 query_fn.contains("sqlx::query!("),
1302 "grouped fn must use sqlx::query!; got:\n{query_fn}"
1303 );
1304 assert!(
1305 query_fn.contains("GetUsersWithOrdersChildRow {"),
1306 "fn must construct child struct; got:\n{query_fn}"
1307 );
1308 assert!(
1309 query_fn.contains("children: vec![child]"),
1310 "fn must initialize children vec; got:\n{query_fn}"
1311 );
1312 assert!(
1313 query_fn.contains("parent.children.push(child)"),
1314 "fn must fold child into existing parent; got:\n{query_fn}"
1315 );
1316 assert!(
1317 query_fn.contains("Ok(result)"),
1318 "fn must return result; got:\n{query_fn}"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_grouped_python_asyncpg_structs() {
1324 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1325 let query = make_grouped_query();
1326 let result = generate_with_backend(&query, &*backend).unwrap();
1327
1328 let row_struct = result.row_struct.as_deref().unwrap();
1329 assert!(
1330 row_struct.contains("class GetUsersWithOrdersChildRow"),
1331 "missing child class; got:\n{row_struct}"
1332 );
1333 assert!(
1334 row_struct.contains("order_id: int"),
1335 "child class missing order_id field; got:\n{row_struct}"
1336 );
1337 assert!(
1338 row_struct.contains("class GetUsersWithOrdersRow"),
1339 "missing parent class; got:\n{row_struct}"
1340 );
1341 assert!(
1342 row_struct.contains("id: int"),
1343 "parent class missing id field; got:\n{row_struct}"
1344 );
1345 assert!(
1346 row_struct.contains("name: str"),
1347 "parent class missing name field; got:\n{row_struct}"
1348 );
1349 assert!(
1350 row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
1351 "parent class missing children field; got:\n{row_struct}"
1352 );
1353 let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1354 let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
1355 assert!(
1356 child_pos < parent_pos,
1357 "child class must be defined before parent class"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_grouped_python_asyncpg_query_fn() {
1363 let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1364 let query = make_grouped_query();
1365 let result = generate_with_backend(&query, &*backend).unwrap();
1366
1367 let query_fn = result.query_fn.as_deref().unwrap();
1368 assert!(
1369 query_fn.contains("async def get_users_with_orders(conn: Connection)"),
1370 "missing fn; got:\n{query_fn}"
1371 );
1372 assert!(
1373 query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
1374 "wrong return type; got:\n{query_fn}"
1375 );
1376 assert!(
1377 query_fn.contains("conn.fetch("),
1378 "fn must use conn.fetch; got:\n{query_fn}"
1379 );
1380 assert!(
1381 query_fn.contains("GetUsersWithOrdersChildRow("),
1382 "fn must construct child class; got:\n{query_fn}"
1383 );
1384 assert!(
1385 query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
1386 "fn must construct parent with **kwargs; got:\n{query_fn}"
1387 );
1388 assert!(
1389 query_fn.contains("_index"),
1390 "fn must use index dict for O(1) lookup; got:\n{query_fn}"
1391 );
1392 }
1393
1394 struct StubBackend {
1400 manifest: BackendManifest,
1401 }
1402
1403 impl CodegenBackend for StubBackend {
1404 fn name(&self) -> &str {
1405 "stub-backend"
1406 }
1407 fn manifest(&self) -> &BackendManifest {
1408 &self.manifest
1409 }
1410 fn manifest_mut(&mut self) -> &mut BackendManifest {
1411 &mut self.manifest
1412 }
1413 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1414 Ok(String::new())
1415 }
1416 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1417 Ok(String::new())
1418 }
1419 fn generate_query_fn(
1420 &self,
1421 _analyzed: &AnalyzedQuery,
1422 _struct_name: &str,
1423 _columns: &[ResolvedColumn],
1424 _params: &[ResolvedParam],
1425 ) -> Result<String, ScytheError> {
1426 Ok(String::new())
1427 }
1428 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1429 Ok(String::new())
1430 }
1431 fn generate_composite_def(
1432 &self,
1433 _composite: &scythe_core::analyzer::CompositeInfo,
1434 ) -> Result<String, ScytheError> {
1435 Ok(String::new())
1436 }
1437 }
1438
1439 #[test]
1440 fn test_grouped_unsupported_backend_returns_clear_error() {
1441 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1442 let backend = StubBackend { manifest };
1443 let query = make_grouped_query();
1444 let result = generate_with_backend(&query, &backend);
1445 assert!(
1446 result.is_err(),
1447 "stub backend should return an error for grouped queries"
1448 );
1449 let err = result.unwrap_err();
1450 assert!(
1451 err.message.contains("not yet supported"),
1452 "error should contain 'not yet supported', got: {}",
1453 err.message
1454 );
1455 assert!(
1456 err.message.contains("stub-backend"),
1457 "error should name the backend, got: {}",
1458 err.message
1459 );
1460 }
1461
1462 #[test]
1463 fn test_tokio_postgres_backend_basic() {
1464 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1465
1466 let query = make_query(
1467 "ListUsers",
1468 QueryCommand::Many,
1469 "SELECT id, name FROM users",
1470 vec![
1471 AnalyzedColumn {
1472 name: "id".to_string(),
1473 neutral_type: "int32".to_string(),
1474 nullable: false,
1475 ..Default::default()
1476 },
1477 AnalyzedColumn {
1478 name: "name".to_string(),
1479 neutral_type: "string".to_string(),
1480 nullable: false,
1481 ..Default::default()
1482 },
1483 ],
1484 vec![],
1485 );
1486
1487 let result = generate_with_backend(&query, &*backend).unwrap();
1488
1489 let row_struct = result.row_struct.unwrap();
1490 assert!(row_struct.contains("pub struct ListUsersRow"));
1491 assert!(row_struct.contains("pub id: i32"));
1492 assert!(row_struct.contains("pub name: String"));
1493 assert!(row_struct.contains("from_row"));
1494 assert!(row_struct.contains("tokio_postgres::Row"));
1495 assert!(!row_struct.contains("sqlx"));
1496
1497 let query_fn = result.query_fn.unwrap();
1498 assert!(query_fn.contains("pub async fn list_users("));
1499 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1500 assert!(query_fn.contains("tokio_postgres::Error"));
1501 assert!(!query_fn.contains("sqlx"));
1502 }
1503
1504 #[test]
1505 fn test_tokio_postgres_enum() {
1506 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1507
1508 let enum_info = scythe_core::analyzer::EnumInfo {
1509 sql_name: "user_status".to_string(),
1510 values: vec!["active".to_string(), "inactive".to_string()],
1511 };
1512
1513 let def = backend.generate_enum_def(&enum_info).unwrap();
1514 assert!(def.contains("pub enum UserStatus"));
1515 assert!(def.contains("Active"));
1516 assert!(def.contains("Inactive"));
1517 assert!(def.contains("impl std::fmt::Display"));
1518 assert!(def.contains("impl std::str::FromStr"));
1519 assert!(!def.contains("sqlx"));
1520 }
1521
1522 #[test]
1523 fn test_sql_with_least_coalesce_sum_preserved() {
1524 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1525
1526 let query = make_query(
1527 "GetBillingAggregates",
1528 QueryCommand::One,
1529 "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
1530 vec![AnalyzedColumn {
1531 name: "aggregated_free_pages".to_string(),
1532 neutral_type: "int64".to_string(),
1533 nullable: false,
1534 ..Default::default()
1535 }],
1536 vec![AnalyzedParam {
1537 name: "customer_id".to_string(),
1538 neutral_type: "uuid".to_string(),
1539 nullable: false,
1540 position: 1,
1541 source_relation: None,
1542 }],
1543 );
1544
1545 let result = generate_with_backend(&query, &*backend).unwrap();
1546 let query_fn = result.query_fn.unwrap();
1547
1548 assert!(
1549 query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
1550 "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
1551 );
1552 assert!(
1553 query_fn.contains("as aggregated_free_pages"),
1554 "SQL should preserve alias keyword (as)"
1555 );
1556 }
1557
1558 #[test]
1559 fn test_generated_rust_code_structure() {
1560 let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1561
1562 let query = make_query(
1563 "GetUser",
1564 QueryCommand::One,
1565 "SELECT id, name FROM users WHERE id = $1",
1566 vec![
1567 AnalyzedColumn {
1568 name: "id".to_string(),
1569 neutral_type: "int32".to_string(),
1570 nullable: false,
1571 ..Default::default()
1572 },
1573 AnalyzedColumn {
1574 name: "name".to_string(),
1575 neutral_type: "string".to_string(),
1576 nullable: false,
1577 ..Default::default()
1578 },
1579 ],
1580 vec![AnalyzedParam {
1581 name: "id".to_string(),
1582 neutral_type: "int32".to_string(),
1583 nullable: false,
1584 position: 1,
1585 source_relation: None,
1586 }],
1587 );
1588
1589 let result = generate_with_backend(&query, &*backend).unwrap();
1590 let query_fn = result.query_fn.unwrap();
1591
1592 assert!(query_fn.contains("pub async fn get_user("));
1593 assert!(query_fn.contains("tokio_postgres::GenericClient"));
1594 assert!(query_fn.contains("GetUserRow"));
1595
1596 assert!(query_fn.contains("SELECT id, name FROM users"));
1597 }
1598
1599 fn a_nested_struct() -> NestedStructInfo {
1604 NestedStructInfo {
1605 name: "get_user_posts_row_posts".to_string(),
1606 fields: vec![NestedFieldInfo {
1607 name: "title".to_string(),
1608 neutral_type: "string".to_string(),
1609 nullable: false,
1610 }],
1611 }
1612 }
1613
1614 #[test]
1615 fn test_nested_struct_shape_unwraps_array() {
1616 let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
1617 assert!(shape.is_array);
1618 assert!(!shape.element_nullable);
1619 assert_eq!(shape.name, "GetUserPostsRowPosts");
1620 }
1621
1622 #[test]
1625 fn test_nested_struct_shape_unwraps_nullable_array_element() {
1626 let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
1627 assert!(shape.is_array);
1628 assert!(shape.element_nullable);
1629 assert_eq!(shape.name, "GetUserPostsRowPosts");
1630 }
1631
1632 #[test]
1633 fn test_nested_struct_shape_bare() {
1634 let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
1635 assert!(!shape.is_array);
1636 assert_eq!(shape.name, "GetPostAsJsonRowPost");
1637 }
1638
1639 #[test]
1645 fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
1646 assert!(nested_struct_shape("json_typed<EventData>").is_none());
1647 }
1648
1649 #[test]
1650 fn test_nested_struct_shape_none_for_ordinary_types() {
1651 assert!(nested_struct_shape("int32").is_none());
1652 assert!(nested_struct_shape("json").is_none());
1653 assert!(nested_struct_shape("array<int32>").is_none());
1654 }
1655
1656 #[test]
1657 fn test_degrade_unsupported_nested_structs_defaults_to_plain_json() {
1658 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1659 let backend = StubBackend { manifest };
1660 let nested = a_nested_struct();
1661 let columns = vec![
1662 AnalyzedColumn {
1663 name: "id".to_string(),
1664 neutral_type: "int32".to_string(),
1665 nullable: false,
1666 ..Default::default()
1667 },
1668 AnalyzedColumn {
1669 name: "posts".to_string(),
1670 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1671 nullable: true,
1672 ..Default::default()
1673 },
1674 AnalyzedColumn {
1675 name: "profile".to_string(),
1676 neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1677 nullable: true,
1678 ..Default::default()
1679 },
1680 ];
1681
1682 let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1683
1684 assert_eq!(
1685 degraded[0].neutral_type, "int32",
1686 "an unrelated column must be untouched"
1687 );
1688 assert_eq!(
1689 degraded[1].neutral_type, "json",
1690 "a backend without an explicit JSON-array mapping must keep the safe plain-json fallback"
1691 );
1692 assert_eq!(
1693 degraded[2].neutral_type, "json",
1694 "an unsupported row_to_json result must remain a single object"
1695 );
1696 assert!(
1697 defs.is_empty(),
1698 "an unsupported backend must not emit a struct definition"
1699 );
1700
1701 assert_eq!(degradations.len(), 2, "only the two nested columns were rewritten");
1706 assert_eq!(degradations[0].column, "posts");
1707 assert_eq!(degradations[0].struct_name, "GetUserPostsRowPosts");
1708 assert_eq!(degradations[0].fallback_type, "json");
1709 assert_eq!(
1710 degradations[0].backend, "stub-backend",
1711 "the backend the user configured, from CodegenBackend::name -- not the manifest it borrows"
1712 );
1713 assert_eq!(degradations[1].column, "profile");
1714 assert_eq!(degradations[1].struct_name, "GetUserPostsRowPosts");
1715 assert_eq!(degradations[1].fallback_type, "json");
1716 assert_eq!(degradations[1].backend, "stub-backend");
1717 }
1718
1719 #[test]
1720 fn test_degrade_unsupported_nested_structs_uses_explicit_json_array_marker() {
1721 let backend = get_backend("typescript-pg", "postgresql").unwrap();
1722 let nested = a_nested_struct();
1723 let columns = vec![
1724 AnalyzedColumn {
1725 name: "posts".to_string(),
1726 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1727 nullable: true,
1728 ..Default::default()
1729 },
1730 AnalyzedColumn {
1731 name: "profile".to_string(),
1732 neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1733 nullable: true,
1734 ..Default::default()
1735 },
1736 ];
1737
1738 let (degraded, defs, degradations) =
1739 degrade_unsupported_nested_structs(&columns, &[nested], &*backend).unwrap();
1740
1741 assert_eq!(degraded[0].neutral_type, "json_array");
1742 assert_eq!(degraded[1].neutral_type, "json");
1743 assert!(defs.is_empty());
1744
1745 assert_eq!(degradations.len(), 2);
1749 assert_eq!(degradations[0].fallback_type, "json_array");
1750 assert_eq!(degradations[0].backend, "typescript-pg");
1751 assert_eq!(degradations[1].fallback_type, "json");
1752 }
1753
1754 struct NestedSupportingBackend {
1757 manifest: BackendManifest,
1758 }
1759
1760 impl CodegenBackend for NestedSupportingBackend {
1761 fn name(&self) -> &str {
1762 "nested-supporting-backend"
1763 }
1764 fn manifest(&self) -> &BackendManifest {
1765 &self.manifest
1766 }
1767 fn manifest_mut(&mut self) -> &mut BackendManifest {
1768 &mut self.manifest
1769 }
1770 fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1771 Ok(String::new())
1772 }
1773 fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1774 Ok(String::new())
1775 }
1776 fn generate_query_fn(
1777 &self,
1778 _analyzed: &AnalyzedQuery,
1779 _struct_name: &str,
1780 _columns: &[ResolvedColumn],
1781 _params: &[ResolvedParam],
1782 ) -> Result<String, ScytheError> {
1783 Ok(String::new())
1784 }
1785 fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1786 Ok(String::new())
1787 }
1788 fn generate_composite_def(
1789 &self,
1790 _composite: &scythe_core::analyzer::CompositeInfo,
1791 ) -> Result<String, ScytheError> {
1792 Ok(String::new())
1793 }
1794 fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
1795 Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
1796 }
1797 }
1798
1799 #[test]
1800 fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
1801 let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1802 let backend = NestedSupportingBackend { manifest };
1803 let nested = a_nested_struct();
1804 let columns = vec![AnalyzedColumn {
1805 name: "posts".to_string(),
1806 neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1807 nullable: true,
1808 ..Default::default()
1809 }];
1810
1811 let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1812
1813 assert_eq!(
1814 degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
1815 "a supported backend must leave the nested reference untouched"
1816 );
1817 assert_eq!(defs.len(), 1);
1818 assert_eq!(defs[0].name, "get_user_posts_row_posts");
1819 assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
1820 assert!(
1821 degradations.is_empty(),
1822 "a supported struct must not be reported as degraded"
1823 );
1824 }
1825
1826 #[test]
1831 fn test_structural_backends_match_json_array_baseline() {
1832 for backend_name in [
1833 "typescript-pg",
1834 "python-asyncpg",
1835 "elixir-postgrex",
1836 "php-pdo",
1837 "ruby-pg",
1838 ] {
1839 let backend = get_backend(backend_name, "postgresql").unwrap();
1840
1841 let baseline = make_query(
1842 "GetUserPosts",
1843 QueryCommand::Many,
1844 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1845 vec![AnalyzedColumn {
1846 name: "posts".to_string(),
1847 neutral_type: "json_array".to_string(),
1848 nullable: true,
1849 ..Default::default()
1850 }],
1851 vec![],
1852 );
1853
1854 let mut nested_query = baseline.clone();
1855 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1856 nested_query.nested_structs = vec![a_nested_struct()];
1857
1858 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1859 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1860
1861 assert_eq!(
1862 baseline_result.row_struct, nested_result.row_struct,
1863 "{backend_name} row struct output must match the JSON array baseline"
1864 );
1865 assert_eq!(
1866 baseline_result.query_fn, nested_result.query_fn,
1867 "{backend_name} query output must match the JSON array baseline"
1868 );
1869 assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1870 }
1871 }
1872
1873 #[test]
1877 fn test_csharp_nested_aggregate_matches_plain_json_baseline() {
1878 let backend = get_backend("csharp-npgsql", "postgresql").unwrap();
1879 let baseline = make_query(
1880 "GetUserPosts",
1881 QueryCommand::Many,
1882 "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1883 vec![AnalyzedColumn {
1884 name: "posts".to_string(),
1885 neutral_type: "json".to_string(),
1886 nullable: true,
1887 ..Default::default()
1888 }],
1889 vec![],
1890 );
1891
1892 let mut nested_query = baseline.clone();
1893 nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1894 nested_query.nested_structs = vec![a_nested_struct()];
1895
1896 let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1897 let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1898
1899 assert_eq!(baseline_result.row_struct, nested_result.row_struct);
1900 assert_eq!(baseline_result.query_fn, nested_result.query_fn);
1901 assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1902 assert_eq!(baseline_result.enum_def, nested_result.enum_def);
1903 assert_eq!(baseline_result.nested_struct_defs, nested_result.nested_struct_defs);
1904 assert!(
1905 nested_result
1906 .row_struct
1907 .as_deref()
1908 .is_some_and(|row| row.contains("string? Posts"))
1909 );
1910 assert!(
1911 nested_result
1912 .query_fn
1913 .as_deref()
1914 .is_some_and(|query_fn| query_fn.contains("reader.GetString(0)"))
1915 );
1916 }
1917
1918 #[test]
1924 fn test_enum_inside_array_is_emitted_not_just_referenced() {
1925 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1926 let mut query = make_query(
1927 "GetMany",
1928 QueryCommand::Many,
1929 "SELECT id, many_status FROM t",
1930 vec![
1931 AnalyzedColumn {
1932 name: "id".to_string(),
1933 neutral_type: "int32".to_string(),
1934 nullable: false,
1935 ..Default::default()
1936 },
1937 AnalyzedColumn {
1938 name: "many_status".to_string(),
1939 neutral_type: "array<enum::user_status>".to_string(),
1940 nullable: false,
1941 ..Default::default()
1942 },
1943 ],
1944 vec![],
1945 );
1946 query.enums = vec![EnumInfo {
1947 sql_name: "user_status".to_string(),
1948 values: vec!["active".to_string(), "banned".to_string()],
1949 }];
1950
1951 let result = generate_with_backend(&query, &*backend).unwrap();
1952
1953 let enum_def = result
1954 .enum_def
1955 .expect("an enum reached only through array<enum::...> must still get a definition emitted");
1956 assert!(
1957 enum_def.contains("pub enum UserStatus"),
1958 "expected a UserStatus enum definition; got:\n{enum_def}"
1959 );
1960
1961 let row_struct = result.row_struct.unwrap();
1962 assert!(
1963 row_struct.contains("Vec<UserStatus>"),
1964 "row struct must reference the array of the enum; got:\n{row_struct}"
1965 );
1966 }
1967
1968 #[test]
1969 fn test_composite_inside_array_is_emitted_not_just_referenced() {
1970 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
1971
1972 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1973 let mut query = make_query(
1974 "GetManyAddrs",
1975 QueryCommand::Many,
1976 "SELECT id, addrs FROM t",
1977 vec![
1978 AnalyzedColumn {
1979 name: "id".to_string(),
1980 neutral_type: "int32".to_string(),
1981 nullable: false,
1982 ..Default::default()
1983 },
1984 AnalyzedColumn {
1985 name: "addrs".to_string(),
1986 neutral_type: "array<composite::address>".to_string(),
1987 nullable: false,
1988 ..Default::default()
1989 },
1990 ],
1991 vec![],
1992 );
1993 query.composites = vec![CompositeInfo {
1994 sql_name: "address".to_string(),
1995 fields: vec![CompositeFieldInfo {
1996 name: "street".to_string(),
1997 neutral_type: "string".to_string(),
1998 }],
1999 }];
2000
2001 let result = generate_with_backend(&query, &*backend).unwrap();
2002
2003 let model = result
2004 .model_struct
2005 .expect("a composite reached only through array<composite::...> must still get a definition emitted");
2006 assert!(
2007 model.contains("pub struct Address"),
2008 "expected an Address composite definition; got:\n{model}"
2009 );
2010 }
2011
2012 #[test]
2024 fn test_composite_reachable_only_through_another_composites_field_is_emitted() {
2025 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2026
2027 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2028 let mut query = make_query(
2029 "GetShape",
2030 QueryCommand::One,
2031 "SELECT id, bounds FROM shapes",
2032 vec![
2033 AnalyzedColumn {
2034 name: "id".to_string(),
2035 neutral_type: "int32".to_string(),
2036 nullable: false,
2037 ..Default::default()
2038 },
2039 AnalyzedColumn {
2040 name: "bounds".to_string(),
2041 neutral_type: "composite::outer_shape".to_string(),
2042 nullable: false,
2043 ..Default::default()
2044 },
2045 ],
2046 vec![],
2047 );
2048 query.composites = vec![
2049 CompositeInfo {
2050 sql_name: "outer_shape".to_string(),
2051 fields: vec![CompositeFieldInfo {
2052 name: "origin".to_string(),
2053 neutral_type: "composite::inner_point".to_string(),
2054 }],
2055 },
2056 CompositeInfo {
2057 sql_name: "inner_point".to_string(),
2058 fields: vec![CompositeFieldInfo {
2059 name: "x".to_string(),
2060 neutral_type: "int32".to_string(),
2061 }],
2062 },
2063 ];
2064
2065 let result = generate_with_backend(&query, &*backend).unwrap();
2066 let model = result
2067 .model_struct
2068 .expect("outer_shape, selected directly, must get a definition emitted");
2069
2070 assert!(
2071 model.contains("pub struct OuterShape"),
2072 "expected an OuterShape composite definition; got:\n{model}"
2073 );
2074 assert!(
2075 model.contains("pub struct InnerPoint"),
2076 "inner_point, reachable only through outer_shape's own field list, must also get a \
2077 definition emitted; got:\n{model}"
2078 );
2079 }
2080
2081 #[test]
2091 fn composites_are_emitted_after_the_composites_their_fields_reference() {
2092 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2093
2094 let composites = vec![
2095 CompositeInfo {
2096 sql_name: "outer_shape".to_string(),
2097 fields: vec![CompositeFieldInfo {
2098 name: "origin".to_string(),
2099 neutral_type: "array<composite::inner_point>".to_string(),
2100 }],
2101 },
2102 CompositeInfo {
2103 sql_name: "inner_point".to_string(),
2104 fields: vec![CompositeFieldInfo {
2105 name: "unit".to_string(),
2106 neutral_type: "composite::scale".to_string(),
2107 }],
2108 },
2109 CompositeInfo {
2110 sql_name: "scale".to_string(),
2111 fields: vec![CompositeFieldInfo {
2112 name: "factor".to_string(),
2113 neutral_type: "int32".to_string(),
2114 }],
2115 },
2116 ];
2117
2118 let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2119 .iter()
2120 .map(|c| c.sql_name.as_str())
2121 .collect();
2122
2123 assert_eq!(ordered, vec!["scale", "inner_point", "outer_shape"]);
2127 }
2128
2129 #[test]
2133 fn composites_in_dependency_order_terminates_on_a_cycle() {
2134 use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2135
2136 let composites = vec![
2137 CompositeInfo {
2138 sql_name: "a".to_string(),
2139 fields: vec![CompositeFieldInfo {
2140 name: "b".to_string(),
2141 neutral_type: "composite::b".to_string(),
2142 }],
2143 },
2144 CompositeInfo {
2145 sql_name: "b".to_string(),
2146 fields: vec![CompositeFieldInfo {
2147 name: "a".to_string(),
2148 neutral_type: "composite::a".to_string(),
2149 }],
2150 },
2151 ];
2152
2153 let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2154 .iter()
2155 .map(|c| c.sql_name.as_str())
2156 .collect();
2157
2158 assert_eq!(ordered.len(), 2, "every composite must still be emitted exactly once");
2159 assert!(ordered.contains(&"a") && ordered.contains(&"b"));
2160 }
2161
2162 #[test]
2163 fn test_unwrap_containers() {
2164 assert_eq!(unwrap_containers("enum::user_status"), "enum::user_status");
2165 assert_eq!(unwrap_containers("array<enum::user_status>"), "enum::user_status");
2166 assert_eq!(unwrap_containers("array<composite::address>"), "composite::address");
2167 assert_eq!(
2168 unwrap_containers("array<nullable<enum::user_status>>"),
2169 "enum::user_status"
2170 );
2171 assert_eq!(unwrap_containers("nullable<array<int32>>"), "int32");
2172 assert_eq!(unwrap_containers("int32"), "int32");
2173 }
2174
2175 #[test]
2181 fn test_grouped_parent_column_named_children_collides_with_synthesized_field() {
2182 let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2183 let parent_cols = vec![
2184 AnalyzedColumn {
2185 name: "id".to_string(),
2186 neutral_type: "int32".to_string(),
2187 nullable: false,
2188 ..Default::default()
2189 },
2190 AnalyzedColumn {
2191 name: "children".to_string(),
2192 neutral_type: "string".to_string(),
2193 nullable: false,
2194 ..Default::default()
2195 },
2196 ];
2197 let child_cols = vec![AnalyzedColumn {
2198 name: "order_id".to_string(),
2199 neutral_type: "int32".to_string(),
2200 nullable: false,
2201 ..Default::default()
2202 }];
2203 let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
2204
2205 let query = AnalyzedQuery::build(|aq| {
2206 aq.name = "GetUsersWithOrders".to_string();
2207 aq.command = QueryCommand::Grouped;
2208 aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
2209 SELECT u.id, u.children, o.id AS order_id\n\
2210 FROM users u\n\
2211 JOIN orders o ON o.user_id = u.id"
2212 .to_string();
2213 aq.columns = all_cols;
2214 aq.params = vec![];
2215 aq.deprecated = None;
2216 aq.source_table = None;
2217 aq.composites = vec![];
2218 aq.enums = vec![];
2219 aq.optional_params = vec![];
2220 aq.group_by = Some(GroupByConfig {
2221 table: "users".to_string(),
2222 key_column: "id".to_string(),
2223 parent_columns: parent_cols,
2224 child_columns: child_cols,
2225 });
2226 aq.custom = vec![];
2227 });
2228
2229 let result = generate_with_backend(&query, &*backend);
2230 let err = result.expect_err(
2231 "a parent column literally named 'children' must be rejected, not silently \
2232 produce two `children` fields on the generated struct",
2233 );
2234 assert_eq!(err.code, ErrorCode::DuplicateAlias);
2235 assert!(
2236 err.message.contains("children"),
2237 "error should name the colliding field, got: {}",
2238 err.message
2239 );
2240 }
2241
2242 fn strip_comments(source: &str) -> String {
2260 let mut out = String::with_capacity(source.len());
2261 let mut i = 0;
2262 while i < source.len() {
2263 let rest = &source[i..];
2264 if rest.starts_with("//") || rest.starts_with('#') {
2265 match rest.find('\n') {
2266 Some(nl) => i += nl,
2267 None => i = source.len(),
2268 }
2269 continue;
2270 }
2271 if rest.starts_with("/*") {
2272 match rest.find("*/") {
2273 Some(end) => i += end + 2,
2274 None => i = source.len(),
2275 }
2276 continue;
2277 }
2278 if let Some(quote) = ["\"\"\"", "'''"].into_iter().find(|q| rest.starts_with(q)) {
2279 match rest[quote.len()..].find(quote) {
2280 Some(end) => i += quote.len() + end + quote.len(),
2281 None => i = source.len(),
2282 }
2283 continue;
2284 }
2285 let ch = rest.chars().next().expect("i < source.len()");
2286 out.push(ch);
2287 i += ch.len_utf8();
2288 }
2289 out
2290 }
2291
2292 fn contains_identifier(haystack: &str, needle: &str) -> bool {
2306 if needle.is_empty() {
2307 return false;
2308 }
2309 let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
2310 let mut start = 0;
2311 while let Some(rel) = haystack[start..].find(needle) {
2312 let idx = start + rel;
2313 let before_ok = haystack[..idx].chars().next_back().is_none_or(|c| !is_ident_char(c));
2314 let after_ok = haystack[idx + needle.len()..]
2315 .chars()
2316 .next()
2317 .is_none_or(|c| !is_ident_char(c));
2318 if before_ok && after_ok {
2319 return true;
2320 }
2321 start = idx + 1;
2322 if start >= haystack.len() {
2323 break;
2324 }
2325 }
2326 false
2327 }
2328
2329 const ALL_BACKEND_NAMES: &[&str] = &[
2332 "rust-sqlx",
2333 "rust-tokio-postgres",
2334 "python-psycopg3",
2335 "python-asyncpg",
2336 "python-aiomysql",
2337 "python-aiosqlite",
2338 "python-duckdb",
2339 "typescript-postgres",
2340 "javascript-postgres",
2341 "typescript-pg",
2342 "javascript-pg",
2343 "typescript-mysql2",
2344 "javascript-mysql2",
2345 "typescript-better-sqlite3",
2346 "javascript-better-sqlite3",
2347 "typescript-duckdb",
2348 "typescript-node-sqlite",
2349 "typescript-wasm-sqlite",
2350 "typescript-kysely",
2351 "go-database-sql",
2352 "go-pgx",
2353 "java-jdbc",
2354 "java-r2dbc",
2355 "kotlin-exposed",
2356 "kotlin-jdbc",
2357 "kotlin-r2dbc",
2358 "csharp-npgsql",
2359 "csharp-mysqlconnector",
2360 "csharp-microsoft-sqlite",
2361 "elixir-postgrex",
2362 "elixir-ecto",
2363 "elixir-myxql",
2364 "elixir-exqlite",
2365 "ruby-pg",
2366 "ruby-mysql2",
2367 "ruby-sqlite3",
2368 "ruby-trilogy",
2369 "php-pdo",
2370 "php-amphp",
2371 "rust-tiberius",
2372 "python-pyodbc",
2373 "typescript-mssql",
2374 "csharp-sqlclient",
2375 "ruby-tiny-tds",
2376 "elixir-tds",
2377 "rust-sibyl",
2378 "python-oracledb",
2379 "typescript-oracledb",
2380 "go-godror",
2381 "csharp-oracle",
2382 "ruby-oci8",
2383 "elixir-jamdb",
2384 "python-snowflake",
2385 "typescript-snowflake",
2386 "go-gosnowflake",
2387 "csharp-snowflake",
2388 ];
2389
2390 fn backend_for_select_star(name: &str) -> Box<dyn CodegenBackend> {
2396 const ENGINES: &[&str] = &[
2397 "postgresql",
2398 "mysql",
2399 "sqlite",
2400 "mssql",
2401 "mariadb",
2402 "duckdb",
2403 "oracle",
2404 "snowflake",
2405 "redshift",
2406 ];
2407 for engine in ENGINES {
2408 if let Ok(backend) = get_backend(name, engine) {
2409 return backend;
2410 }
2411 }
2412 panic!("no known engine works for backend '{name}'");
2413 }
2414
2415 fn select_star_query() -> AnalyzedQuery {
2416 let mut query = make_query(
2417 "GetAllUsers",
2418 QueryCommand::Many,
2419 "SELECT * FROM users",
2420 vec![
2421 AnalyzedColumn {
2422 name: "id".to_string(),
2423 neutral_type: "int32".to_string(),
2424 nullable: false,
2425 ..Default::default()
2426 },
2427 AnalyzedColumn {
2428 name: "name".to_string(),
2429 neutral_type: "string".to_string(),
2430 nullable: false,
2431 ..Default::default()
2432 },
2433 ],
2434 vec![],
2435 );
2436 query.source_table = Some("users".to_string());
2437 query
2438 }
2439
2440 struct BackendNote {
2444 backend: &'static str,
2445 reason: &'static str,
2446 }
2447
2448 const KNOWN_DIVERGENT_BACKENDS: &[BackendNote] = &[];
2479
2480 const NOT_APPLICABLE_BACKENDS: &[BackendNote] = &[
2498 BackendNote {
2499 backend: "javascript-postgres",
2500 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2501 the check to find once comments are stripped",
2502 },
2503 BackendNote {
2504 backend: "javascript-pg",
2505 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2506 the check to find once comments are stripped",
2507 },
2508 BackendNote {
2509 backend: "javascript-mysql2",
2510 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2511 the check to find once comments are stripped",
2512 },
2513 BackendNote {
2514 backend: "javascript-better-sqlite3",
2515 reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2516 the check to find once comments are stripped",
2517 },
2518 ];
2519
2520 #[test]
2528 fn test_select_star_declares_and_references_the_same_struct_name_across_all_backends() {
2529 let query = select_star_query();
2530
2531 let mut regressions = Vec::new();
2532 let mut stale_entries = Vec::new();
2533 let mut seen_not_applicable: Vec<&str> = Vec::new();
2534
2535 for &name in ALL_BACKEND_NAMES {
2536 if let Some(note) = NOT_APPLICABLE_BACKENDS.iter().find(|n| n.backend == name) {
2537 seen_not_applicable.push(note.backend);
2538 continue;
2539 }
2540
2541 let backend = backend_for_select_star(name);
2542 let struct_name = determine_struct_name(&query, backend.manifest());
2543
2544 let agrees = match generate_with_backend(&query, &*backend) {
2545 Err(_) => false,
2546 Ok(result) => match (result.model_struct, result.query_fn) {
2547 (Some(model_struct), Some(query_fn)) => {
2548 let declared = contains_identifier(&strip_comments(&model_struct), &struct_name);
2549 let referenced = contains_identifier(&strip_comments(&query_fn), &struct_name);
2550 declared && referenced
2551 }
2552 _ => false,
2553 },
2554 };
2555
2556 let listed = KNOWN_DIVERGENT_BACKENDS.iter().find(|n| n.backend == name);
2557 match (agrees, listed) {
2558 (true, Some(_)) => stale_entries.push(name),
2559 (false, None) => regressions.push(name),
2560 (false, Some(note)) => eprintln!("expected divergence, {name}: {}", note.reason),
2564 (true, None) => {}
2565 }
2566 }
2567
2568 let unknown_not_applicable: Vec<&str> = NOT_APPLICABLE_BACKENDS
2574 .iter()
2575 .map(|n| n.backend)
2576 .filter(|b| !seen_not_applicable.contains(b))
2577 .collect();
2578
2579 let mut failures = Vec::new();
2580 for name in ®ressions {
2581 let reason = "diverges on SELECT * struct naming and is not in KNOWN_DIVERGENT_BACKENDS";
2582 failures.push(format!(
2583 "REGRESSION: {name}: {reason} -- investigate before adding a line"
2584 ));
2585 }
2586 for name in &stale_entries {
2587 failures.push(format!(
2588 "STALE ALLOWLIST: {name}: listed in KNOWN_DIVERGENT_BACKENDS but now agrees -- delete its entry"
2589 ));
2590 }
2591 for name in &unknown_not_applicable {
2592 failures.push(format!(
2593 "STALE ALLOWLIST: {name}: listed in NOT_APPLICABLE_BACKENDS but is not in ALL_BACKEND_NAMES"
2594 ));
2595 }
2596
2597 assert!(
2598 failures.is_empty(),
2599 "{} of {} backends need attention:\n{}",
2600 failures.len(),
2601 ALL_BACKEND_NAMES.len(),
2602 failures.join("\n")
2603 );
2604 }
2605}