Skip to main content

scythe_codegen/
lib.rs

1pub mod backend_options;
2pub mod backend_trait;
3pub mod backends;
4pub mod overrides;
5pub mod provenance;
6pub mod resolve;
7pub mod validation;
8
9pub use backend_trait::{
10    CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
11};
12pub use backends::get_backend;
13pub use overrides::TypeOverride;
14
15use scythe_backend::manifest::BackendManifest;
16use scythe_backend::naming::{row_struct_name, to_pascal_case};
17
18use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, EnumInfo, NestedStructInfo};
19use scythe_core::catalog::Catalog;
20use scythe_core::errors::{ErrorCode, ScytheError};
21use scythe_core::parser::QueryCommand;
22
23#[derive(Debug, Clone, Default)]
24#[non_exhaustive]
25pub struct GeneratedCode {
26    pub query_fn: Option<String>,
27    pub row_struct: Option<String>,
28    pub model_struct: Option<String>,
29    pub enum_def: Option<String>,
30    /// Nested-aggregate struct definitions this query needs, one entry per
31    /// [`scythe_core::analyzer::NestedStructInfo`] the backend opted into.
32    ///
33    /// Kept out of `model_struct` (where composites go) because these have
34    /// to be deduplicated *across* the queries written into one file: two
35    /// queries can legitimately derive the same struct name, and emitting
36    /// the definition twice is E0428 in Rust and a redeclaration in every
37    /// other target. `model_struct` is already a rendered blob with no
38    /// identity attached, so the name has to survive alongside the code for
39    /// the writer to dedupe on. See `scythe-cli`'s `generate_for_backend`.
40    pub nested_struct_defs: Vec<NestedStructDef>,
41}
42
43impl GeneratedCode {
44    /// Build a `GeneratedCode` from [`Default`], assigning fields inside
45    /// `init`.
46    ///
47    /// `#[non_exhaustive]` forbids a struct literal outside this crate --
48    /// including with `..Default::default()` -- so downstream crates
49    /// (`scythe-cli`, and anyone embedding the generator) have no other way
50    /// to construct one. Mirrors
51    /// [`scythe_core::analyzer::AnalyzedQuery::build`], and keeping
52    /// construction a single expression avoids clippy's
53    /// `field_reassign_with_default`.
54    ///
55    /// ```
56    /// use scythe_codegen::GeneratedCode;
57    ///
58    /// let code = GeneratedCode::build(|c| {
59    ///     c.query_fn = Some("fn get_user() {}".to_string());
60    /// });
61    /// assert!(code.row_struct.is_none());
62    /// ```
63    #[must_use]
64    pub fn build(init: impl FnOnce(&mut Self)) -> Self {
65        let mut code = Self::default();
66        init(&mut code);
67        code
68    }
69}
70
71/// One rendered nested-aggregate struct definition, paired with the name it
72/// declares so file writers can deduplicate.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct NestedStructDef {
75    /// snake_case name from [`scythe_core::analyzer::NestedStructInfo::name`],
76    /// which is unique per (name, shape) within one query.
77    pub name: String,
78    /// The backend's rendered definition.
79    pub code: String,
80}
81
82/// Simple singularization: remove trailing 's'.
83pub fn singularize(name: &str) -> String {
84    if let Some(stem) = name.strip_suffix("ies") {
85        format!("{stem}y")
86    } else if name.ends_with("sses")
87        || name.ends_with("shes")
88        || name.ends_with("ches")
89        || name.ends_with("xes")
90        || name.ends_with("zes")
91        || name.ends_with("ses")
92    {
93        name[..name.len() - 2].to_string()
94    } else if name.ends_with('s') && !name.ends_with("ss") {
95        name[..name.len() - 1].to_string()
96    } else {
97        name.to_string()
98    }
99}
100
101/// Get the manifest for a backend. Defaults to PostgreSQL engine.
102pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
103    let backend = get_backend(backend_name, "postgresql")?;
104    Ok(backend.manifest().clone())
105}
106
107/// Determine the struct name for a query (model struct or row struct).
108fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
109    if let Some(ref table_name) = analyzed.source_table {
110        let singular = singularize(table_name);
111        to_pascal_case(&singular).into_owned()
112    } else {
113        row_struct_name(&analyzed.name, &manifest.naming)
114    }
115}
116
117/// Generate code using a specific backend.
118pub fn generate_with_backend(
119    analyzed: &AnalyzedQuery,
120    backend: &dyn CodegenBackend,
121) -> Result<GeneratedCode, ScytheError> {
122    generate_with_backend_and_overrides(analyzed, backend, &[])
123}
124
125/// Generate code using a specific backend with type overrides.
126pub fn generate_with_backend_and_overrides(
127    analyzed: &AnalyzedQuery,
128    backend: &dyn CodegenBackend,
129    overrides: &[TypeOverride],
130) -> Result<GeneratedCode, ScytheError> {
131    let manifest = backend.manifest();
132    let source_table = analyzed.source_table.as_deref().unwrap_or("");
133
134    // Degradation pass: must run before any resolve_columns call. A backend
135    // that doesn't opt into a nested struct (generate_nested_struct_def
136    // returns Ok(None)) never sees json_nested<...> referencing it -- the
137    // column is rewritten to plain json first, matching this backend's
138    // output from before nested-aggregate inference existed byte for byte.
139    //
140    // Skipped entirely when nested_structs is empty (the overwhelming
141    // majority of queries) so the common path stays the zero-copy
142    // `&analyzed.columns` it always was -- degrade_unsupported_nested_structs
143    // would otherwise clone every column, String fields included, on every
144    // call regardless of whether the feature is in use.
145    let (degraded_columns, nested_struct_defs) = if analyzed.nested_structs.is_empty() {
146        (None, Vec::new())
147    } else {
148        let (cols, defs) = degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
149        (Some(cols), defs)
150    };
151    let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
152
153    let columns = resolve::resolve_columns(
154        degraded_columns.as_deref().unwrap_or(&analyzed.columns),
155        manifest,
156        overrides,
157        source_table,
158    )?;
159    let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
160
161    let mut result = GeneratedCode::default();
162
163    let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs)?;
164    if !enum_def.is_empty() {
165        result.enum_def = Some(enum_def);
166    }
167
168    let needs_row_struct = matches!(
169        analyzed.command,
170        QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
171    );
172    if needs_row_struct && !analyzed.columns.is_empty() {
173        if let Some(ref table_name) = analyzed.source_table {
174            result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
175        } else {
176            result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
177        }
178    }
179
180    let mut extra_defs = String::new();
181    for comp in &analyzed.composites {
182        // `analyzed.composites` covers types reachable from the top-level
183        // columns *and* from nested-aggregate fields. A composite reachable
184        // only through a nested struct this backend degraded away would be
185        // an unused definition that did not exist before nested-aggregate
186        // inference, breaking the byte-identity guarantee the degradation
187        // pass exists to provide.
188        let from_nested = nested_refs.composites.contains(comp.sql_name.as_str());
189        if !from_nested && !type_referenced_by_columns(analyzed, "composite::", &comp.sql_name) {
190            continue;
191        }
192        if !extra_defs.is_empty() {
193            extra_defs.push_str("\n\n");
194        }
195        if from_nested {
196            extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
197        } else {
198            extra_defs.push_str(&backend.generate_composite_def(comp)?);
199        }
200    }
201    if !extra_defs.is_empty() {
202        if let Some(ref mut existing) = result.model_struct {
203            existing.push_str("\n\n");
204            existing.push_str(&extra_defs);
205        } else {
206            result.model_struct = Some(extra_defs);
207        }
208    }
209    result.nested_struct_defs = nested_struct_defs;
210
211    let struct_name = determine_struct_name(analyzed, manifest);
212
213    if analyzed.command == QueryCommand::Grouped {
214        let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
215            ScytheError::new(
216                ErrorCode::InternalError,
217                format!(
218                    "query '{}' is :grouped but is missing @group_by annotation",
219                    analyzed.name
220                ),
221            )
222        })?;
223
224        let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
225        let child_struct_name = {
226            let suffix = &manifest.naming.row_suffix;
227            let base = parent_struct_name.trim_end_matches(suffix.as_str());
228            format!("{}Child{}", base, suffix)
229        };
230
231        // Same degradation pass as the flat-column path above, and the same
232        // empty-nested_structs skip to keep the common case zero-copy: a
233        // :grouped query's parent/child split is a distinct copy of the
234        // analyzed columns, so an unsupported nested reference there needs
235        // its own rewrite before resolution -- but only when there is one.
236        let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
237            (None, None)
238        } else {
239            let (dp, _) =
240                degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
241            let (dc, _) =
242                degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
243            (Some(dp), Some(dc))
244        };
245        let parent_cols = resolve::resolve_columns(
246            degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
247            manifest,
248            overrides,
249            source_table,
250        )?;
251        let child_cols = resolve::resolve_columns(
252            degraded_child.as_deref().unwrap_or(&group_by.child_columns),
253            manifest,
254            overrides,
255            source_table,
256        )?;
257
258        result.row_struct = Some(backend.generate_grouped_structs(
259            &parent_struct_name,
260            &child_struct_name,
261            &parent_cols,
262            &child_cols,
263            &group_by.key_column,
264        )?);
265        result.query_fn = Some(
266            backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
267                analyzed,
268                parent_struct_name: &parent_struct_name,
269                child_struct_name: &child_struct_name,
270                all_columns: &columns,
271                parent_columns: &parent_cols,
272                child_columns: &child_cols,
273                params: &params,
274                key_column: &group_by.key_column,
275            })?,
276        );
277    } else {
278        result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, &params)?);
279    }
280
281    Ok(result)
282}
283
284/// Catalog types reachable from a nested-aggregate struct the backend
285/// actually opted into.
286///
287/// A nested struct is decoded from JSON, so every type appearing in one of
288/// its fields must be decodable that way too — which for the Rust backends
289/// means an extra `serde::Deserialize`/`Serialize` the plain
290/// `generate_enum_def`/`generate_composite_def` output does not carry (those
291/// derive only the driver's own `sqlx::Type` / `postgres_types` traits). The
292/// requirement is a property of *where the type is used*, not of the type,
293/// so it cannot live in `EnumInfo`/`CompositeInfo`; it is computed here and
294/// routed to the `*_for_nested` variants of those two trait methods.
295///
296/// Built from the definitions the backend returned rather than from
297/// `analyzed.nested_structs`, so a backend that degraded the nested column
298/// back to plain `json` also keeps its enum output byte-identical.
299#[derive(Debug, Default)]
300struct NestedTypeRefs {
301    enums: ahash::AHashSet<String>,
302    composites: ahash::AHashSet<String>,
303}
304
305impl NestedTypeRefs {
306    fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
307        let mut refs = Self::default();
308        for nested in &analyzed.nested_structs {
309            if !supported.iter().any(|def| def.name == nested.name) {
310                continue;
311            }
312            for field in &nested.fields {
313                if let Some(name) = field.neutral_type.strip_prefix("enum::") {
314                    refs.enums.insert(name.to_string());
315                } else if let Some(name) = field.neutral_type.strip_prefix("composite::") {
316                    refs.composites.insert(name.to_string());
317                }
318            }
319        }
320        refs
321    }
322}
323
324/// Whether a query's top-level columns or params reference `sql_name` under
325/// `prefix` (`"enum::"` or `"composite::"`).
326///
327/// Matches the exact `prefix + sql_name` neutral type, the same rule
328/// `generate_enum_defs_via_backend` has always used, so "reachable from
329/// columns" means the same thing in both places.
330fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
331    let neutral = format!("{prefix}{sql_name}");
332    analyzed.columns.iter().any(|col| col.neutral_type == neutral)
333        || analyzed.params.iter().any(|param| param.neutral_type == neutral)
334}
335
336/// Whether `sql_name` is reachable from a nested-aggregate struct the
337/// backend actually emitted a definition for.
338///
339/// The `supported` filter is what keeps a degraded backend's output
340/// byte-identical: a type reachable only through a nested struct that was
341/// rewritten back to plain `json` must not be emitted at all.
342pub fn nested_type_is_emitted(
343    analyzed: &AnalyzedQuery,
344    supported: &[NestedStructDef],
345    prefix: &str,
346    sql_name: &str,
347) -> bool {
348    let neutral = format!("{prefix}{sql_name}");
349    analyzed
350        .nested_structs
351        .iter()
352        .filter(|nested| supported.iter().any(|def| def.name == nested.name))
353        .any(|nested| nested.fields.iter().any(|field| field.neutral_type == neutral))
354}
355
356/// Generate enum definitions via the backend trait.
357fn generate_enum_defs_via_backend(
358    analyzed: &AnalyzedQuery,
359    backend: &dyn CodegenBackend,
360    nested_refs: &NestedTypeRefs,
361) -> Result<String, ScytheError> {
362    use ahash::AHashSet;
363    use std::fmt::Write;
364
365    let mut out = String::new();
366    let mut seen_enums: AHashSet<String> = AHashSet::new();
367
368    let enum_sources: Vec<&str> = analyzed
369        .columns
370        .iter()
371        .filter_map(|col| col.neutral_type.strip_prefix("enum::"))
372        .chain(
373            analyzed
374                .params
375                .iter()
376                .filter_map(|p| p.neutral_type.strip_prefix("enum::")),
377        )
378        .chain(nested_refs.enums.iter().map(String::as_str))
379        .collect();
380
381    for sql_name in enum_sources {
382        if !seen_enums.insert(sql_name.to_string()) {
383            continue;
384        }
385
386        if !out.is_empty() {
387            let _ = writeln!(out);
388        }
389
390        let stub_info;
391        let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
392            Some(info) => info,
393            None => {
394                stub_info = EnumInfo {
395                    sql_name: sql_name.to_string(),
396                    values: vec![],
397                };
398                &stub_info
399            }
400        };
401        if nested_refs.enums.contains(sql_name) {
402            out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
403        } else {
404            out.push_str(&backend.generate_enum_def(enum_info)?);
405        }
406    }
407
408    Ok(out)
409}
410
411/// The decomposed form of a `json_nested<...>` neutral type.
412#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub(crate) struct NestedColumnShape<'a> {
414    /// `json_agg` produces an array of objects; `row_to_json` a single one.
415    pub(crate) is_array: bool,
416    /// Only ever true for an array: `json_agg` over an outer join emits
417    /// `[null]` when nothing matched, so the *element* is optional. See
418    /// `Analyzer::infer_nested_aggregate_type`.
419    pub(crate) element_nullable: bool,
420    /// PascalCase name of the generated struct.
421    pub(crate) name: &'a str,
422}
423
424/// Decompose a `json_nested<...>` neutral type, or `None` for anything else.
425///
426/// `json_nested` is the container the analyzer emits for a nested aggregate,
427/// deliberately distinct from `json_typed`, which is what a user's own
428/// `@json` annotation produces (`analyzer/mod.rs`). Keeping them apart is
429/// what lets this function -- and every backend decision keyed off it --
430/// mean "a struct *scythe* synthesized and knows the field-by-field shape
431/// of", rather than "some type the user named". Matching `json_typed` here
432/// would make a user's `@json EventData` mapping (which may be a
433/// `TypedDict`, a JSON array shape, or not constructible from a mapping at
434/// all) take the nested-construction path.
435///
436/// ```text
437/// "json_nested<array<nullable<GetUserPostsRowPosts>>>" -> array, nullable elements
438/// "json_nested<array<GetUserPostsRowPosts>>"           -> array, non-null elements
439/// "json_nested<GetPostAsJsonRowPost>"                  -> single object
440/// "json_typed<EventData>", "int32", "json", "array<int32>" -> None
441/// ```
442pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
443    let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
444    let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
445        return Some(NestedColumnShape {
446            is_array: false,
447            element_nullable: false,
448            name: inner,
449        });
450    };
451    match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
452        Some(name) => Some(NestedColumnShape {
453            is_array: true,
454            element_nullable: true,
455            name,
456        }),
457        None => Some(NestedColumnShape {
458            is_array: true,
459            element_nullable: false,
460            name: element,
461        }),
462    }
463}
464
465/// `nested_struct_shape` reduced to just the struct name, for callers (the
466/// degradation pass) that don't care how it is wrapped.
467fn nested_struct_pascal_name(neutral_type: &str) -> Option<&str> {
468    nested_struct_shape(neutral_type).map(|shape| shape.name)
469}
470
471/// Degradation pass for nested-aggregate columns (`json_agg(o.*)`,
472/// `row_to_json(u.*)`). Must run before `resolve::resolve_columns` -- every
473/// caller that resolves `AnalyzedColumn`s into a backend's types needs this,
474/// not just [`generate_with_backend_and_overrides`]: `scythe-cli`'s RBS
475/// signature generation (`generate_rbs_if_supported`) calls
476/// `resolve::resolve_columns` directly on a second, independent path, so it
477/// must run this pass too or it silently bypasses the byte-identity
478/// guarantee for any backend that hasn't opted in.
479///
480/// For each entry in `nested_structs`, asks the backend whether it opts in
481/// via [`CodegenBackend::generate_nested_struct_def`]:
482/// - `Ok(Some(def))`: the struct is supported. Its definition is collected
483///   to append to the backend's output (the same channel
484///   `generate_composite_def` output goes through), and columns
485///   referencing it are left as `json_nested<...>`.
486/// - `Ok(None)` (the default): not supported. Every column referencing it
487///   is rewritten to plain `json` -- byte-identical to this backend's
488///   output before nested-aggregate inference existed, since that is
489///   exactly what the pre-existing `json_agg`/`row_to_json` arms already
490///   produced.
491/// - `Err(_)`: a genuine failure, propagated rather than degraded.
492///
493/// Returns the (possibly rewritten) columns and one [`NestedStructDef`] per
494/// struct the backend supports. Skip calling this entirely when
495/// `nested_structs` is empty (the common case) to keep that path zero-copy
496/// -- see the callers in this file for the pattern.
497pub fn degrade_unsupported_nested_structs(
498    columns: &[AnalyzedColumn],
499    nested_structs: &[NestedStructInfo],
500    backend: &dyn CodegenBackend,
501) -> Result<(Vec<AnalyzedColumn>, Vec<NestedStructDef>), ScytheError> {
502    if nested_structs.is_empty() {
503        return Ok((columns.to_vec(), Vec::new()));
504    }
505
506    use ahash::AHashSet;
507
508    let mut unsupported: AHashSet<String> = AHashSet::new();
509    let mut defs: Vec<NestedStructDef> = Vec::new();
510    for nested in nested_structs {
511        match backend.generate_nested_struct_def(nested)? {
512            Some(def) => defs.push(NestedStructDef {
513                name: nested.name.clone(),
514                code: def,
515            }),
516            None => {
517                unsupported.insert(to_pascal_case(&nested.name).into_owned());
518            }
519        }
520    }
521
522    if unsupported.is_empty() {
523        return Ok((columns.to_vec(), defs));
524    }
525
526    let degraded = columns
527        .iter()
528        .cloned()
529        .map(|mut col| {
530            if let Some(name) = nested_struct_pascal_name(&col.neutral_type)
531                && unsupported.contains(name)
532            {
533                col.neutral_type = "json".to_string();
534            }
535            col
536        })
537        .collect();
538
539    Ok((degraded, defs))
540}
541
542/// Backward-compatible: generate code using the default sqlx backend.
543pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
544    let backend = get_backend("rust-sqlx", "postgresql")?;
545    generate_with_backend(analyzed, &*backend)
546}
547
548/// Stub for catalog-level codegen. Returns default for now.
549pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
550    Ok(GeneratedCode::default())
551}
552
553/// Generate a single enum definition using a specific backend.
554///
555/// `reachable_from_nested` selects [`CodegenBackend::generate_enum_def_for_nested`]
556/// instead of the plain form. A file writer that emits enums once for the
557/// whole file must pass `true` when *any* query in that file reaches the
558/// enum from a nested-aggregate field: one definition serves every use, and
559/// the nested-capable form is a superset (extra derives), so widening is
560/// safe where narrowing is not.
561pub fn generate_single_enum_def_with_backend(
562    enum_info: &EnumInfo,
563    backend: &dyn CodegenBackend,
564    reachable_from_nested: bool,
565) -> Result<String, ScytheError> {
566    if reachable_from_nested {
567        backend.generate_enum_def_for_nested(enum_info)
568    } else {
569        backend.generate_enum_def(enum_info)
570    }
571}
572
573/// Backward-compatible: generate a single enum definition (sqlx backend).
574/// Uses the manifest directly for backward compatibility with existing callers.
575pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
576    use scythe_backend::naming::{enum_type_name, enum_variant_name};
577    use std::fmt::Write;
578
579    let mut out = String::with_capacity(256);
580    let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
581
582    let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
583    let _ = writeln!(
584        out,
585        "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
586        enum_info.sql_name
587    );
588    let _ = writeln!(out, "pub enum {type_name} {{");
589
590    for value in &enum_info.values {
591        let variant = enum_variant_name(value, &manifest.naming);
592        let _ = writeln!(out, "    {variant},");
593    }
594
595    let _ = write!(out, "}}");
596    out
597}
598
599/// Backward-compatible: load the default sqlx manifest.
600pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
601    let b = backends::sqlx::SqlxBackend::new("postgresql")?;
602    Ok(b.manifest().clone())
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
609    use scythe_core::parser::QueryCommand;
610
611    fn make_query(
612        name: &str,
613        command: QueryCommand,
614        sql: &str,
615        columns: Vec<AnalyzedColumn>,
616        params: Vec<AnalyzedParam>,
617    ) -> AnalyzedQuery {
618        AnalyzedQuery::build(|aq| {
619            aq.name = name.to_string();
620            aq.command = command;
621            aq.sql = sql.to_string();
622            aq.columns = columns;
623            aq.params = params;
624            aq.deprecated = None;
625            aq.source_table = None;
626            aq.composites = Vec::new();
627            aq.enums = Vec::new();
628            aq.optional_params = Vec::new();
629            aq.group_by = None;
630            aq.custom = Vec::new();
631        })
632    }
633
634    #[test]
635    fn test_generate_select_many() {
636        let query = make_query(
637            "ListUsers",
638            QueryCommand::Many,
639            "SELECT id, name, email FROM users",
640            vec![
641                AnalyzedColumn {
642                    name: "id".to_string(),
643                    neutral_type: "int32".to_string(),
644                    nullable: false,
645                    ..Default::default()
646                },
647                AnalyzedColumn {
648                    name: "name".to_string(),
649                    neutral_type: "string".to_string(),
650                    nullable: false,
651                    ..Default::default()
652                },
653                AnalyzedColumn {
654                    name: "email".to_string(),
655                    neutral_type: "string".to_string(),
656                    nullable: true,
657                    ..Default::default()
658                },
659            ],
660            vec![],
661        );
662
663        let result = generate(&query).unwrap();
664
665        let row_struct = result.row_struct.unwrap();
666        assert!(row_struct.contains("pub struct ListUsersRow"));
667        assert!(row_struct.contains("pub id: i32"));
668        assert!(row_struct.contains("pub name: String"));
669        assert!(row_struct.contains("pub email: Option<String>"));
670
671        let query_fn = result.query_fn.unwrap();
672        assert!(query_fn.contains("pub async fn list_users("));
673        assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
674        assert!(query_fn.contains(".fetch_all(pool)"));
675    }
676
677    #[test]
678    fn test_generate_select_one_with_param() {
679        let query = make_query(
680            "GetUser",
681            QueryCommand::One,
682            "SELECT id, name FROM users WHERE id = $1",
683            vec![
684                AnalyzedColumn {
685                    name: "id".to_string(),
686                    neutral_type: "int32".to_string(),
687                    nullable: false,
688                    ..Default::default()
689                },
690                AnalyzedColumn {
691                    name: "name".to_string(),
692                    neutral_type: "string".to_string(),
693                    nullable: false,
694                    ..Default::default()
695                },
696            ],
697            vec![AnalyzedParam {
698                name: "id".to_string(),
699                neutral_type: "int32".to_string(),
700                nullable: false,
701                position: 1,
702            }],
703        );
704
705        let result = generate(&query).unwrap();
706
707        let query_fn = result.query_fn.unwrap();
708        assert!(query_fn.contains("pub async fn get_user("));
709        assert!(query_fn.contains("id: i32"));
710        assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
711        assert!(query_fn.contains(".fetch_one(pool)"));
712    }
713
714    #[test]
715    fn test_generate_exec() {
716        let query = make_query(
717            "DeleteUser",
718            QueryCommand::Exec,
719            "DELETE FROM users WHERE id = $1",
720            vec![],
721            vec![AnalyzedParam {
722                name: "id".to_string(),
723                neutral_type: "int32".to_string(),
724                nullable: false,
725                position: 1,
726            }],
727        );
728
729        let result = generate(&query).unwrap();
730
731        assert!(result.row_struct.is_none());
732
733        let query_fn = result.query_fn.unwrap();
734        assert!(query_fn.contains("pub async fn delete_user("));
735        assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
736        assert!(query_fn.contains(".execute(pool)"));
737    }
738
739    #[test]
740    fn test_generate_with_enum_column() {
741        let query = make_query(
742            "GetUserStatus",
743            QueryCommand::One,
744            "SELECT id, status FROM users WHERE id = $1",
745            vec![
746                AnalyzedColumn {
747                    name: "id".to_string(),
748                    neutral_type: "int32".to_string(),
749                    nullable: false,
750                    ..Default::default()
751                },
752                AnalyzedColumn {
753                    name: "status".to_string(),
754                    neutral_type: "enum::user_status".to_string(),
755                    nullable: false,
756                    ..Default::default()
757                },
758            ],
759            vec![AnalyzedParam {
760                name: "id".to_string(),
761                neutral_type: "int32".to_string(),
762                nullable: false,
763                position: 1,
764            }],
765        );
766
767        let result = generate(&query).unwrap();
768
769        assert!(result.enum_def.is_some());
770        let enum_def = result.enum_def.unwrap();
771        assert!(enum_def.contains("pub enum UserStatus"));
772        assert!(enum_def.contains("type_name = \"user_status\""));
773
774        let row_struct = result.row_struct.unwrap();
775        assert!(row_struct.contains("pub status: UserStatus"));
776    }
777
778    #[test]
779    fn test_generate_from_catalog_returns_default() {
780        let catalog = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER);"]).unwrap();
781        let result = generate_from_catalog(&catalog).unwrap();
782        assert!(result.query_fn.is_none());
783        assert!(result.row_struct.is_none());
784    }
785
786    #[test]
787    fn test_singularize_basic() {
788        assert_eq!(singularize("users"), "user");
789        assert_eq!(singularize("orders"), "order");
790        assert_eq!(singularize("posts"), "post");
791    }
792
793    #[test]
794    fn test_singularize_ies() {
795        assert_eq!(singularize("categories"), "category");
796        assert_eq!(singularize("entries"), "entry");
797    }
798
799    #[test]
800    fn test_singularize_sses() {
801        assert_eq!(singularize("addresses"), "address");
802        assert_eq!(singularize("classes"), "class");
803    }
804
805    #[test]
806    fn test_singularize_no_change() {
807        assert_eq!(singularize("status"), "statu");
808        assert_eq!(singularize("boss"), "boss");
809        assert_eq!(singularize("address"), "address");
810    }
811
812    #[test]
813    fn test_singularize_shes_ches_xes() {
814        assert_eq!(singularize("batches"), "batch");
815        assert_eq!(singularize("boxes"), "box");
816        assert_eq!(singularize("wishes"), "wish");
817    }
818
819    fn make_grouped_query() -> AnalyzedQuery {
820        let parent_cols = vec![
821            AnalyzedColumn {
822                name: "id".to_string(),
823                neutral_type: "int32".to_string(),
824                nullable: false,
825                ..Default::default()
826            },
827            AnalyzedColumn {
828                name: "name".to_string(),
829                neutral_type: "string".to_string(),
830                nullable: false,
831                ..Default::default()
832            },
833            AnalyzedColumn {
834                name: "email".to_string(),
835                neutral_type: "string".to_string(),
836                nullable: false,
837                ..Default::default()
838            },
839        ];
840        let child_cols = vec![
841            AnalyzedColumn {
842                name: "order_id".to_string(),
843                neutral_type: "int32".to_string(),
844                nullable: false,
845                ..Default::default()
846            },
847            AnalyzedColumn {
848                name: "total".to_string(),
849                neutral_type: "decimal".to_string(),
850                nullable: true,
851                ..Default::default()
852            },
853            AnalyzedColumn {
854                name: "order_date".to_string(),
855                neutral_type: "datetime".to_string(),
856                nullable: false,
857                ..Default::default()
858            },
859        ];
860        let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
861
862        AnalyzedQuery::build(|aq| {
863            aq.name = "GetUsersWithOrders".to_string();
864            aq.command = QueryCommand::Grouped;
865            aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
866                  SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
867                  FROM users u\n\
868                  JOIN orders o ON o.user_id = u.id"
869                .to_string();
870            aq.columns = all_cols;
871            aq.params = vec![];
872            aq.deprecated = None;
873            aq.source_table = None;
874            aq.composites = vec![];
875            aq.enums = vec![];
876            aq.optional_params = vec![];
877            aq.group_by = Some(GroupByConfig {
878                table: "users".to_string(),
879                key_column: "id".to_string(),
880                parent_columns: parent_cols,
881                child_columns: child_cols,
882            });
883            aq.custom = vec![];
884        })
885    }
886
887    #[test]
888    fn test_grouped_sqlx_structs() {
889        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
890        let query = make_grouped_query();
891        let result = generate_with_backend(&query, &*backend).unwrap();
892
893        let row_struct = result.row_struct.as_deref().unwrap();
894        assert!(
895            row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
896            "missing child struct; got:\n{row_struct}"
897        );
898        assert!(
899            row_struct.contains("pub order_id: i32"),
900            "child struct missing order_id field; got:\n{row_struct}"
901        );
902        assert!(
903            row_struct.contains("pub struct GetUsersWithOrdersRow"),
904            "missing parent struct; got:\n{row_struct}"
905        );
906        assert!(
907            row_struct.contains("pub id: i32"),
908            "parent struct missing id field; got:\n{row_struct}"
909        );
910        assert!(
911            row_struct.contains("pub name: String"),
912            "parent struct missing name field; got:\n{row_struct}"
913        );
914        assert!(
915            row_struct.contains("pub email: String"),
916            "parent struct missing email field; got:\n{row_struct}"
917        );
918        assert!(
919            row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
920            "parent struct missing children field; got:\n{row_struct}"
921        );
922        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
923        let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
924        assert!(
925            child_pos < parent_pos,
926            "child struct must be defined before parent struct"
927        );
928
929        assert!(
930            result.model_struct.is_none(),
931            "grouped should not produce a model_struct"
932        );
933    }
934
935    #[test]
936    fn test_grouped_sqlx_query_fn() {
937        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
938        let query = make_grouped_query();
939        let result = generate_with_backend(&query, &*backend).unwrap();
940
941        let query_fn = result.query_fn.as_deref().unwrap();
942        assert!(
943            query_fn.contains("pub async fn get_users_with_orders("),
944            "missing fn; got:\n{query_fn}"
945        );
946        assert!(
947            query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
948            "wrong return type; got:\n{query_fn}"
949        );
950        assert!(
951            query_fn.contains("sqlx::query!("),
952            "grouped fn must use sqlx::query!; got:\n{query_fn}"
953        );
954        assert!(
955            query_fn.contains("GetUsersWithOrdersChildRow {"),
956            "fn must construct child struct; got:\n{query_fn}"
957        );
958        assert!(
959            query_fn.contains("children: vec![child]"),
960            "fn must initialize children vec; got:\n{query_fn}"
961        );
962        assert!(
963            query_fn.contains("parent.children.push(child)"),
964            "fn must fold child into existing parent; got:\n{query_fn}"
965        );
966        assert!(
967            query_fn.contains("Ok(result)"),
968            "fn must return result; got:\n{query_fn}"
969        );
970    }
971
972    #[test]
973    fn test_grouped_python_asyncpg_structs() {
974        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
975        let query = make_grouped_query();
976        let result = generate_with_backend(&query, &*backend).unwrap();
977
978        let row_struct = result.row_struct.as_deref().unwrap();
979        assert!(
980            row_struct.contains("class GetUsersWithOrdersChildRow"),
981            "missing child class; got:\n{row_struct}"
982        );
983        assert!(
984            row_struct.contains("order_id: int"),
985            "child class missing order_id field; got:\n{row_struct}"
986        );
987        assert!(
988            row_struct.contains("class GetUsersWithOrdersRow"),
989            "missing parent class; got:\n{row_struct}"
990        );
991        assert!(
992            row_struct.contains("id: int"),
993            "parent class missing id field; got:\n{row_struct}"
994        );
995        assert!(
996            row_struct.contains("name: str"),
997            "parent class missing name field; got:\n{row_struct}"
998        );
999        assert!(
1000            row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
1001            "parent class missing children field; got:\n{row_struct}"
1002        );
1003        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1004        let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
1005        assert!(
1006            child_pos < parent_pos,
1007            "child class must be defined before parent class"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_grouped_python_asyncpg_query_fn() {
1013        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1014        let query = make_grouped_query();
1015        let result = generate_with_backend(&query, &*backend).unwrap();
1016
1017        let query_fn = result.query_fn.as_deref().unwrap();
1018        assert!(
1019            query_fn.contains("async def get_users_with_orders(conn: Connection)"),
1020            "missing fn; got:\n{query_fn}"
1021        );
1022        assert!(
1023            query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
1024            "wrong return type; got:\n{query_fn}"
1025        );
1026        assert!(
1027            query_fn.contains("conn.fetch("),
1028            "fn must use conn.fetch; got:\n{query_fn}"
1029        );
1030        assert!(
1031            query_fn.contains("GetUsersWithOrdersChildRow("),
1032            "fn must construct child class; got:\n{query_fn}"
1033        );
1034        assert!(
1035            query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
1036            "fn must construct parent with **kwargs; got:\n{query_fn}"
1037        );
1038        assert!(
1039            query_fn.contains("_index"),
1040            "fn must use index dict for O(1) lookup; got:\n{query_fn}"
1041        );
1042    }
1043
1044    /// Minimal backend that implements only the required trait methods and
1045    /// leaves the grouped methods as their default (erroring) impl. Every
1046    /// shipped backend now supports grouped codegen, so the "not yet supported"
1047    /// path can only be exercised by a backend that has not opted in — this stub
1048    /// stands in for such a backend so the contract stays covered.
1049    struct StubBackend {
1050        manifest: BackendManifest,
1051    }
1052
1053    impl CodegenBackend for StubBackend {
1054        fn name(&self) -> &str {
1055            "stub-backend"
1056        }
1057        fn manifest(&self) -> &BackendManifest {
1058            &self.manifest
1059        }
1060        fn manifest_mut(&mut self) -> &mut BackendManifest {
1061            &mut self.manifest
1062        }
1063        fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1064            Ok(String::new())
1065        }
1066        fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1067            Ok(String::new())
1068        }
1069        fn generate_query_fn(
1070            &self,
1071            _analyzed: &AnalyzedQuery,
1072            _struct_name: &str,
1073            _columns: &[ResolvedColumn],
1074            _params: &[ResolvedParam],
1075        ) -> Result<String, ScytheError> {
1076            Ok(String::new())
1077        }
1078        fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1079            Ok(String::new())
1080        }
1081        fn generate_composite_def(
1082            &self,
1083            _composite: &scythe_core::analyzer::CompositeInfo,
1084        ) -> Result<String, ScytheError> {
1085            Ok(String::new())
1086        }
1087    }
1088
1089    #[test]
1090    fn test_grouped_unsupported_backend_returns_clear_error() {
1091        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1092        let backend = StubBackend { manifest };
1093        let query = make_grouped_query();
1094        let result = generate_with_backend(&query, &backend);
1095        assert!(
1096            result.is_err(),
1097            "stub backend should return an error for grouped queries"
1098        );
1099        let err = result.unwrap_err();
1100        assert!(
1101            err.message.contains("not yet supported"),
1102            "error should contain 'not yet supported', got: {}",
1103            err.message
1104        );
1105        assert!(
1106            err.message.contains("stub-backend"),
1107            "error should name the backend, got: {}",
1108            err.message
1109        );
1110    }
1111
1112    #[test]
1113    fn test_tokio_postgres_backend_basic() {
1114        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1115
1116        let query = make_query(
1117            "ListUsers",
1118            QueryCommand::Many,
1119            "SELECT id, name FROM users",
1120            vec![
1121                AnalyzedColumn {
1122                    name: "id".to_string(),
1123                    neutral_type: "int32".to_string(),
1124                    nullable: false,
1125                    ..Default::default()
1126                },
1127                AnalyzedColumn {
1128                    name: "name".to_string(),
1129                    neutral_type: "string".to_string(),
1130                    nullable: false,
1131                    ..Default::default()
1132                },
1133            ],
1134            vec![],
1135        );
1136
1137        let result = generate_with_backend(&query, &*backend).unwrap();
1138
1139        let row_struct = result.row_struct.unwrap();
1140        assert!(row_struct.contains("pub struct ListUsersRow"));
1141        assert!(row_struct.contains("pub id: i32"));
1142        assert!(row_struct.contains("pub name: String"));
1143        assert!(row_struct.contains("from_row"));
1144        assert!(row_struct.contains("tokio_postgres::Row"));
1145        assert!(!row_struct.contains("sqlx"));
1146
1147        let query_fn = result.query_fn.unwrap();
1148        assert!(query_fn.contains("pub async fn list_users("));
1149        assert!(query_fn.contains("tokio_postgres::GenericClient"));
1150        assert!(query_fn.contains("tokio_postgres::Error"));
1151        assert!(!query_fn.contains("sqlx"));
1152    }
1153
1154    #[test]
1155    fn test_tokio_postgres_enum() {
1156        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1157
1158        let enum_info = scythe_core::analyzer::EnumInfo {
1159            sql_name: "user_status".to_string(),
1160            values: vec!["active".to_string(), "inactive".to_string()],
1161        };
1162
1163        let def = backend.generate_enum_def(&enum_info).unwrap();
1164        assert!(def.contains("pub enum UserStatus"));
1165        assert!(def.contains("Active"));
1166        assert!(def.contains("Inactive"));
1167        assert!(def.contains("impl std::fmt::Display"));
1168        assert!(def.contains("impl std::str::FromStr"));
1169        assert!(!def.contains("sqlx"));
1170    }
1171
1172    #[test]
1173    fn test_sql_with_least_coalesce_sum_preserved() {
1174        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1175
1176        let query = make_query(
1177            "GetBillingAggregates",
1178            QueryCommand::One,
1179            "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
1180            vec![AnalyzedColumn {
1181                name: "aggregated_free_pages".to_string(),
1182                neutral_type: "int64".to_string(),
1183                nullable: false,
1184                ..Default::default()
1185            }],
1186            vec![AnalyzedParam {
1187                name: "customer_id".to_string(),
1188                neutral_type: "uuid".to_string(),
1189                nullable: false,
1190                position: 1,
1191            }],
1192        );
1193
1194        let result = generate_with_backend(&query, &*backend).unwrap();
1195        let query_fn = result.query_fn.unwrap();
1196
1197        assert!(
1198            query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
1199            "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
1200        );
1201        assert!(
1202            query_fn.contains("as aggregated_free_pages"),
1203            "SQL should preserve alias keyword (as)"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_generated_rust_code_structure() {
1209        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1210
1211        let query = make_query(
1212            "GetUser",
1213            QueryCommand::One,
1214            "SELECT id, name FROM users WHERE id = $1",
1215            vec![
1216                AnalyzedColumn {
1217                    name: "id".to_string(),
1218                    neutral_type: "int32".to_string(),
1219                    nullable: false,
1220                    ..Default::default()
1221                },
1222                AnalyzedColumn {
1223                    name: "name".to_string(),
1224                    neutral_type: "string".to_string(),
1225                    nullable: false,
1226                    ..Default::default()
1227                },
1228            ],
1229            vec![AnalyzedParam {
1230                name: "id".to_string(),
1231                neutral_type: "int32".to_string(),
1232                nullable: false,
1233                position: 1,
1234            }],
1235        );
1236
1237        let result = generate_with_backend(&query, &*backend).unwrap();
1238        let query_fn = result.query_fn.unwrap();
1239
1240        assert!(query_fn.contains("pub async fn get_user("));
1241        assert!(query_fn.contains("tokio_postgres::GenericClient"));
1242        assert!(query_fn.contains("GetUserRow"));
1243
1244        assert!(query_fn.contains("SELECT id, name FROM users"));
1245    }
1246
1247    // -----------------------------------------------------------------
1248    // Nested-aggregate degradation pass (degrade_unsupported_nested_structs).
1249    // -----------------------------------------------------------------
1250
1251    fn a_nested_struct() -> NestedStructInfo {
1252        NestedStructInfo {
1253            name: "get_user_posts_row_posts".to_string(),
1254            fields: vec![NestedFieldInfo {
1255                name: "title".to_string(),
1256                neutral_type: "string".to_string(),
1257                nullable: false,
1258            }],
1259        }
1260    }
1261
1262    #[test]
1263    fn test_nested_struct_shape_unwraps_array() {
1264        let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
1265        assert!(shape.is_array);
1266        assert!(!shape.element_nullable);
1267        assert_eq!(shape.name, "GetUserPostsRowPosts");
1268    }
1269
1270    /// The LEFT JOIN form: `json_agg` emits `[null]` for a non-matching row,
1271    /// so the analyzer wraps the element in `nullable<>`.
1272    #[test]
1273    fn test_nested_struct_shape_unwraps_nullable_array_element() {
1274        let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
1275        assert!(shape.is_array);
1276        assert!(shape.element_nullable);
1277        assert_eq!(shape.name, "GetUserPostsRowPosts");
1278    }
1279
1280    #[test]
1281    fn test_nested_struct_shape_bare() {
1282        let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
1283        assert!(!shape.is_array);
1284        assert_eq!(shape.name, "GetPostAsJsonRowPost");
1285    }
1286
1287    /// The N9 regression: a user's own `@json` mapping resolves to
1288    /// `json_typed<...>`, a type scythe knows nothing about. It must not be
1289    /// mistaken for a struct scythe synthesized, or backends keyed off this
1290    /// (python-psycopg3's row construction) start calling constructors on a
1291    /// type that may not have one.
1292    #[test]
1293    fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
1294        assert!(nested_struct_shape("json_typed<EventData>").is_none());
1295        assert!(nested_struct_pascal_name("json_typed<EventData>").is_none());
1296    }
1297
1298    #[test]
1299    fn test_nested_struct_pascal_name_none_for_ordinary_types() {
1300        assert_eq!(nested_struct_pascal_name("int32"), None);
1301        assert_eq!(nested_struct_pascal_name("json"), None);
1302        assert_eq!(nested_struct_pascal_name("array<int32>"), None);
1303    }
1304
1305    #[test]
1306    fn test_degrade_unsupported_nested_structs_rewrites_to_plain_json() {
1307        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1308        let backend = StubBackend { manifest };
1309        let nested = a_nested_struct();
1310        let columns = vec![
1311            AnalyzedColumn {
1312                name: "id".to_string(),
1313                neutral_type: "int32".to_string(),
1314                nullable: false,
1315                ..Default::default()
1316            },
1317            AnalyzedColumn {
1318                name: "posts".to_string(),
1319                neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1320                nullable: true,
1321                ..Default::default()
1322            },
1323        ];
1324
1325        let (degraded, defs) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1326
1327        assert_eq!(
1328            degraded[0].neutral_type, "int32",
1329            "an unrelated column must be untouched"
1330        );
1331        assert_eq!(
1332            degraded[1].neutral_type, "json",
1333            "StubBackend does not override generate_nested_struct_def, so it must degrade to plain json"
1334        );
1335        assert!(
1336            defs.is_empty(),
1337            "an unsupported backend must not emit a struct definition"
1338        );
1339    }
1340
1341    /// Minimal backend that opts into nested-struct support, to prove the
1342    /// degradation pass leaves a supported column and definition alone.
1343    struct NestedSupportingBackend {
1344        manifest: BackendManifest,
1345    }
1346
1347    impl CodegenBackend for NestedSupportingBackend {
1348        fn name(&self) -> &str {
1349            "nested-supporting-backend"
1350        }
1351        fn manifest(&self) -> &BackendManifest {
1352            &self.manifest
1353        }
1354        fn manifest_mut(&mut self) -> &mut BackendManifest {
1355            &mut self.manifest
1356        }
1357        fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1358            Ok(String::new())
1359        }
1360        fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1361            Ok(String::new())
1362        }
1363        fn generate_query_fn(
1364            &self,
1365            _analyzed: &AnalyzedQuery,
1366            _struct_name: &str,
1367            _columns: &[ResolvedColumn],
1368            _params: &[ResolvedParam],
1369        ) -> Result<String, ScytheError> {
1370            Ok(String::new())
1371        }
1372        fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1373            Ok(String::new())
1374        }
1375        fn generate_composite_def(
1376            &self,
1377            _composite: &scythe_core::analyzer::CompositeInfo,
1378        ) -> Result<String, ScytheError> {
1379            Ok(String::new())
1380        }
1381        fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
1382            Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
1383        }
1384    }
1385
1386    #[test]
1387    fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
1388        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1389        let backend = NestedSupportingBackend { manifest };
1390        let nested = a_nested_struct();
1391        let columns = vec![AnalyzedColumn {
1392            name: "posts".to_string(),
1393            neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1394            nullable: true,
1395            ..Default::default()
1396        }];
1397
1398        let (degraded, defs) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1399
1400        assert_eq!(
1401            degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
1402            "a supported backend must leave the nested reference untouched"
1403        );
1404        assert_eq!(defs.len(), 1);
1405        assert_eq!(defs[0].name, "get_user_posts_row_posts");
1406        assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
1407    }
1408
1409    /// The review check for this batch: for a backend that does not opt in,
1410    /// generated output for a nested-aggregate column must be byte-identical
1411    /// to what the same backend produces for an ordinary plain-`json` column
1412    /// -- proving the degradation pass, not a per-backend special case,
1413    /// is what keeps ~44 non-opted-in backends safe.
1414    #[test]
1415    fn test_unopted_backend_output_is_byte_identical_to_plain_json_baseline() {
1416        let backend = get_backend("java-jdbc", "postgresql").unwrap();
1417
1418        let baseline = make_query(
1419            "GetUserPosts",
1420            QueryCommand::Many,
1421            "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1422            vec![AnalyzedColumn {
1423                name: "posts".to_string(),
1424                neutral_type: "json".to_string(),
1425                nullable: true,
1426                ..Default::default()
1427            }],
1428            vec![],
1429        );
1430
1431        let mut nested_query = baseline.clone();
1432        nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1433        nested_query.nested_structs = vec![a_nested_struct()];
1434
1435        let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1436        let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1437
1438        assert_eq!(
1439            baseline_result.row_struct, nested_result.row_struct,
1440            "row struct output must match byte for byte"
1441        );
1442        assert_eq!(
1443            baseline_result.query_fn, nested_result.query_fn,
1444            "query fn output must match byte for byte"
1445        );
1446        assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1447    }
1448}