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 sql_literal;
8pub mod validation;
9
10pub use backend_trait::{
11    CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
12};
13pub use backends::get_backend;
14pub use overrides::TypeOverride;
15
16use ahash::AHashSet;
17use scythe_backend::manifest::BackendManifest;
18use scythe_backend::naming::{NamingConfig, apply_case, row_struct_name, sanitize_for_identifier, to_pascal_case};
19
20use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, CompositeInfo, EnumInfo, NestedStructInfo};
21use scythe_core::errors::{ErrorCode, ScytheError};
22use scythe_core::parser::QueryCommand;
23
24/// SQL-level name of the field every `generate_grouped_structs`
25/// implementation synthesizes on a `:grouped` query's parent struct to hold
26/// the folded child rows. Named once here so the collision guard in
27/// [`generate_with_backend_and_overrides`] and each backend's synthesized
28/// field agree on what name they're both talking about (#188).
29const GROUPED_CHILDREN_FIELD: &str = "children";
30
31#[derive(Debug, Clone, Default)]
32#[non_exhaustive]
33pub struct GeneratedCode {
34    pub query_fn: Option<String>,
35    pub row_struct: Option<String>,
36    pub model_struct: Option<String>,
37    pub enum_def: Option<String>,
38    /// Nested-aggregate struct definitions this query needs, one entry per
39    /// [`scythe_core::analyzer::NestedStructInfo`] the backend opted into.
40    ///
41    /// Kept out of `model_struct` (where composites go) because these have
42    /// to be deduplicated *across* the queries written into one file: two
43    /// queries can legitimately derive the same struct name, and emitting
44    /// the definition twice is E0428 in Rust and a redeclaration in every
45    /// other target. `model_struct` is already a rendered blob with no
46    /// identity attached, so the name has to survive alongside the code for
47    /// the writer to dedupe on. See `scythe-cli`'s `generate_for_backend`.
48    pub nested_struct_defs: Vec<NestedStructDef>,
49    /// One entry per column [`degrade_unsupported_nested_structs`] rewrote
50    /// because `backend` did not opt the referenced struct into
51    /// [`CodegenBackend::generate_nested_struct_def`] (GH #147).
52    ///
53    /// Populated even though the rewrite itself is not an error. Counting by
54    /// all 102 manifests overstates the reach: `catalog_has_nested_aggregates`
55    /// (`scythe-core`, `analyzer/expressions.rs`) only infers a nested
56    /// aggregate for the PostgreSQL dialect on a postgresql-family engine --
57    /// Redshift and DuckDB are excluded by name even though `SqlDialect`
58    /// maps them onto PostgreSQL -- so only the **19** postgresql-engine
59    /// manifests can reach this code at all. Of those, 4 build a real struct
60    /// (`generate_nested_struct_def`), 9 keep the array shape via
61    /// `json_array` (including `ruby-pg`, as of GH #147's fix -- its
62    /// `ruby_coercion` now genuinely decodes JSON, so the `json_array`
63    /// marker is honest there too), and **6** collapse to plain `json`:
64    /// csharp-npgsql, java-jdbc, java-r2dbc, kotlin-exposed, kotlin-jdbc and
65    /// kotlin-r2dbc. Failing those 6 by default would break working setups.
66    /// But the rewrite is a
67    /// silent narrowing (a structured row collapses to an opaque JSON
68    /// string or array scalar a caller must parse itself), and before this
69    /// field existed nothing told the caller it happened at all: `scythe
70    /// generate` exited 0 either way. A caller (`scythe-cli`) is expected to
71    /// turn each entry into a reported finding; see
72    /// [`degrade_unsupported_nested_structs`]'s doc comment for exactly what
73    /// each field means.
74    pub degraded_nested_structs: Vec<NestedStructDegradation>,
75}
76
77impl GeneratedCode {
78    /// Build a `GeneratedCode` from [`Default`], assigning fields inside
79    /// `init`.
80    ///
81    /// `#[non_exhaustive]` forbids a struct literal outside this crate --
82    /// including with `..Default::default()` -- so downstream crates
83    /// (`scythe-cli`, and anyone embedding the generator) have no other way
84    /// to construct one. Mirrors
85    /// [`scythe_core::analyzer::AnalyzedQuery::build`], and keeping
86    /// construction a single expression avoids clippy's
87    /// `field_reassign_with_default`.
88    ///
89    /// ```
90    /// use scythe_codegen::GeneratedCode;
91    ///
92    /// let code = GeneratedCode::build(|c| {
93    ///     c.query_fn = Some("fn get_user() {}".to_string());
94    /// });
95    /// assert!(code.row_struct.is_none());
96    /// ```
97    #[must_use]
98    pub fn build(init: impl FnOnce(&mut Self)) -> Self {
99        let mut code = Self::default();
100        init(&mut code);
101        code
102    }
103}
104
105/// One rendered nested-aggregate struct definition, paired with the name it
106/// declares so file writers can deduplicate.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct NestedStructDef {
109    /// snake_case name from [`scythe_core::analyzer::NestedStructInfo::name`],
110    /// which is unique per (name, shape) within one query.
111    pub name: String,
112    /// The backend's rendered definition.
113    pub code: String,
114}
115
116/// One nested-aggregate column [`degrade_unsupported_nested_structs`]
117/// rewrote to a scalar JSON fallback because `backend` could not construct
118/// a real struct for it. See [`GeneratedCode::degraded_nested_structs`] for
119/// why this is surfaced at all rather than left as an internal rewrite
120/// (GH #147).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct NestedStructDegradation {
123    /// SQL-level name of the rewritten column
124    /// ([`scythe_core::analyzer::AnalyzedColumn::name`]).
125    pub column: String,
126    /// PascalCase name of the struct that could not be generated -- matches
127    /// [`nested_struct_shape`]'s decoded `name`, not the snake_case
128    /// [`scythe_core::analyzer::NestedStructInfo::name`] it was cased from.
129    pub struct_name: String,
130    /// The neutral type the column was rewritten to: `"json"` or, when the
131    /// manifest declares the `json_array` scalar and the shape was an
132    /// array, `"json_array"`.
133    pub fallback_type: &'static str,
134    /// [`scythe_backend::manifest::BackendManifest::name`] of the backend
135    /// that declined the struct.
136    pub backend: String,
137}
138
139/// Simple singularization: remove trailing 's'.
140pub fn singularize(name: &str) -> String {
141    if let Some(stem) = name.strip_suffix("ies") {
142        format!("{stem}y")
143    } else if name.ends_with("sses")
144        || name.ends_with("shes")
145        || name.ends_with("ches")
146        || name.ends_with("xes")
147        || name.ends_with("zes")
148        || name.ends_with("ses")
149    {
150        name[..name.len() - 2].to_string()
151    } else if name.ends_with('s') && !name.ends_with("ss") {
152        name[..name.len() - 1].to_string()
153    } else {
154        name.to_string()
155    }
156}
157
158/// Get the manifest for a backend. Defaults to PostgreSQL engine.
159pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
160    let backend = get_backend(backend_name, "postgresql")?;
161    Ok(backend.manifest().clone())
162}
163
164/// The type name a `SELECT *` model is declared under *and* referenced by.
165///
166/// The single source of truth for #164. Both sides of that defect now call
167/// this: [`determine_struct_name`] below, which decides what the generated
168/// query function references, and [`CodegenBackend::generate_model_struct`],
169/// which decides what gets declared. There is no longer a second derivation
170/// for them to drift apart from.
171///
172/// Deliberately does not apply [`row_struct_name`]'s `row_suffix`. The
173/// suffix exists to keep a per-query row type (`GetActiveUsersRow`) from
174/// colliding with the query function that returns it; a table model is not
175/// a per-query type, and suffixing it would make `SELECT * FROM users`
176/// declare `UserRow` — the very name a query named `User` already claims.
177///
178/// `table_name` reaches here from `analyzed.source_table`, which is not
179/// guaranteed to already be an identifier: `SELECT * FROM app.widgets`
180/// carries the schema-qualifying `.` straight through
181/// `detect_select_star_source` (unlike a query's `@name`, which the parser
182/// already validates as letters/digits/underscore only), and `apply_case`
183/// alone does not remove it -- the same shape as `enum_type_name`'s #136,
184/// just for a model struct instead of an enum. Routed through the same
185/// [`sanitize_for_identifier`] before singularizing and casing, so the `.`
186/// becomes `_` before either runs.
187pub fn model_struct_name(table_name: &str, naming: &NamingConfig) -> String {
188    let sanitized = sanitize_for_identifier(table_name);
189    apply_case(&singularize(&sanitized), &naming.struct_case).into_owned()
190}
191
192/// Determine the struct name for a query (model struct or row struct).
193fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
194    if let Some(ref table_name) = analyzed.source_table {
195        model_struct_name(table_name, &manifest.naming)
196    } else {
197        row_struct_name(&analyzed.name, &manifest.naming)
198    }
199}
200
201/// Generate code using a specific backend.
202pub fn generate_with_backend(
203    analyzed: &AnalyzedQuery,
204    backend: &dyn CodegenBackend,
205) -> Result<GeneratedCode, ScytheError> {
206    generate_with_backend_and_overrides(analyzed, backend, &[])
207}
208
209/// Generate code using a specific backend with type overrides.
210pub fn generate_with_backend_and_overrides(
211    analyzed: &AnalyzedQuery,
212    backend: &dyn CodegenBackend,
213    overrides: &[TypeOverride],
214) -> Result<GeneratedCode, ScytheError> {
215    generate_with_backend_and_overrides_and_nested_composites(analyzed, backend, overrides, &AHashSet::new())
216}
217
218/// Generate code while promoting the named composites to their nested-capable form.
219pub fn generate_with_backend_and_overrides_and_nested_composites(
220    analyzed: &AnalyzedQuery,
221    backend: &dyn CodegenBackend,
222    overrides: &[TypeOverride],
223    nested_composite_names: &AHashSet<String>,
224) -> Result<GeneratedCode, ScytheError> {
225    let manifest = backend.manifest();
226    let source_table = analyzed.source_table.as_deref().unwrap_or("");
227
228    // ~keep Degradation pass: must run before any resolve_columns call. A backend
229    // that doesn't opt into a nested struct (generate_nested_struct_def
230    // returns Ok(None)) never sees json_nested<...> referencing it -- the
231    // column is rewritten to a manifest-supported fallback first: plain
232    // json by default, or the distinct json_array scalar for an array-shaped
233    // document when the manifest explicitly declares that representation.
234    //
235    // Skipped entirely when nested_structs is empty (the overwhelming
236    // majority of queries) so the common path stays the zero-copy
237    // `&analyzed.columns` it always was -- degrade_unsupported_nested_structs
238    // would otherwise clone every column, String fields included, on every
239    // call regardless of whether the feature is in use.
240    let (degraded_columns, nested_struct_defs, nested_degradations) = if analyzed.nested_structs.is_empty() {
241        (None, Vec::new(), Vec::new())
242    } else {
243        let (cols, defs, degradations) =
244            degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
245        (Some(cols), defs, degradations)
246    };
247    let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
248
249    let columns = resolve::resolve_columns(
250        degraded_columns.as_deref().unwrap_or(&analyzed.columns),
251        manifest,
252        overrides,
253    )?;
254    let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
255
256    let mut result = GeneratedCode::default();
257
258    // Computed here, ahead of enum generation, so the collision check below
259    // can compare an enum's generated name against the same row/model type
260    // name the rest of this function later declares -- not a second,
261    // independent derivation of it.
262    let struct_name = determine_struct_name(analyzed, manifest);
263    let needs_row_struct = matches!(
264        analyzed.command,
265        QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
266    );
267    // `:grouped` declares its own parent/child struct names below and never
268    // reaches this one, so passing it into the enum collision check when
269    // `analyzed.columns` is empty (or the command has no row struct at all)
270    // would compare against a name this query never actually declares.
271    let declared_struct_name = (needs_row_struct && !analyzed.columns.is_empty()).then_some(struct_name.as_str());
272
273    let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs, declared_struct_name)?;
274    if !enum_def.is_empty() {
275        result.enum_def = Some(enum_def);
276    }
277
278    if needs_row_struct && !analyzed.columns.is_empty() {
279        if let Some(ref table_name) = analyzed.source_table {
280            result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
281        } else {
282            result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
283        }
284    }
285
286    let mut extra_defs = String::new();
287    let reachable_composites = reachable_composite_names(analyzed, &nested_refs);
288    for comp in composites_in_dependency_order(&analyzed.composites) {
289        // `analyzed.composites` covers types reachable from the top-level
290        // columns, from nested-aggregate fields, and (recursively) from
291        // another reachable composite's own field list --
292        // `reachable_composite_names` walks that last edge, which neither
293        // `nested_refs` nor `type_referenced_by_columns` follows on its own
294        // (unfiled). A composite reachable only through a nested struct this
295        // backend degraded away would be an unused definition that did not
296        // exist before nested-aggregate inference, even though the degraded
297        // output has no generated type that could reference it.
298        if !reachable_composites.contains(comp.sql_name.as_str()) {
299            continue;
300        }
301        let from_nested = nested_refs.composites.contains(comp.sql_name.as_str())
302            || nested_composite_names.contains(comp.sql_name.as_str());
303        if !extra_defs.is_empty() {
304            extra_defs.push_str("\n\n");
305        }
306        if from_nested {
307            extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
308        } else {
309            extra_defs.push_str(&backend.generate_composite_def(comp)?);
310        }
311    }
312    if !extra_defs.is_empty() {
313        if let Some(ref mut existing) = result.model_struct {
314            existing.push_str("\n\n");
315            existing.push_str(&extra_defs);
316        } else {
317            result.model_struct = Some(extra_defs);
318        }
319    }
320    result.nested_struct_defs = nested_struct_defs;
321    result.degraded_nested_structs = nested_degradations;
322
323    if analyzed.command == QueryCommand::Grouped {
324        let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
325            ScytheError::new(
326                ErrorCode::InternalError,
327                format!(
328                    "query '{}' is :grouped but is missing @group_by annotation",
329                    analyzed.name
330                ),
331            )
332        })?;
333
334        let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
335        let child_struct_name = {
336            let suffix = &manifest.naming.row_suffix;
337            let base = parent_struct_name.trim_end_matches(suffix.as_str());
338            format!("{}Child{}", base, suffix)
339        };
340
341        // Same degradation pass as the flat-column path above, and the same
342        // empty-nested_structs skip to keep the common case zero-copy: a
343        // :grouped query's parent/child split is a distinct copy of the
344        // analyzed columns, so an unsupported nested reference there needs
345        // its own rewrite before resolution -- but only when there is one.
346        let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
347            (None, None)
348        } else {
349            let (dp, _, parent_degradations) =
350                degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
351            let (dc, _, child_degradations) =
352                degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
353            result.degraded_nested_structs.extend(parent_degradations);
354            result.degraded_nested_structs.extend(child_degradations);
355            (Some(dp), Some(dc))
356        };
357        let parent_cols = resolve::resolve_columns(
358            degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
359            manifest,
360            overrides,
361        )?;
362        let child_cols = resolve::resolve_columns(
363            degraded_child.as_deref().unwrap_or(&group_by.child_columns),
364            manifest,
365            overrides,
366        )?;
367
368        // Every `generate_grouped_structs` implementation injects one more
369        // field into the parent struct beyond `parent_cols`: `children`
370        // (`GROUPED_CHILDREN_FIELD`), holding the folded child rows. That
371        // field is synthesized here, not resolved from SQL, so it never
372        // passes through `resolve::resolve_columns` -- which is exactly why
373        // `check_field_name_collisions` inside it never sees it. Running the
374        // same collision check again here, against the parent columns plus
375        // this synthesized field, is the single rule that also catches a
376        // real `u.children` column colliding with it (#188) instead of a
377        // second, independent "no collisions" check that only ever covers
378        // SQL-derived columns.
379        let children_field_name = scythe_backend::naming::field_name(GROUPED_CHILDREN_FIELD, &manifest.naming);
380        resolve::check_field_name_collisions(
381            parent_cols
382                .iter()
383                .map(|c| (c.name.as_str(), c.field_name.as_str()))
384                .chain(std::iter::once((
385                    "<synthesized :grouped children field>",
386                    children_field_name.as_ref(),
387                ))),
388            "parent columns",
389            &manifest.naming,
390        )?;
391
392        result.row_struct = Some(backend.generate_grouped_structs(
393            &parent_struct_name,
394            &child_struct_name,
395            &parent_cols,
396            &child_cols,
397            &group_by.key_column,
398        )?);
399        result.query_fn = Some(
400            backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
401                analyzed,
402                parent_struct_name: &parent_struct_name,
403                child_struct_name: &child_struct_name,
404                all_columns: &columns,
405                parent_columns: &parent_cols,
406                child_columns: &child_cols,
407                params: &params,
408                key_column: &group_by.key_column,
409            })?,
410        );
411    } else {
412        result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, &params)?);
413    }
414
415    Ok(result)
416}
417
418/// Catalog types reachable from a nested-aggregate struct the backend
419/// actually opted into.
420///
421/// A nested struct is decoded from JSON, so every type appearing in one of
422/// its fields must be decodable that way too — which for the Rust backends
423/// means an extra `serde::Deserialize`/`Serialize` the plain
424/// `generate_enum_def`/`generate_composite_def` output does not carry (those
425/// derive only the driver's own `sqlx::Type` / `postgres_types` traits). The
426/// requirement is a property of *where the type is used*, not of the type,
427/// so it cannot live in `EnumInfo`/`CompositeInfo`; it is computed here and
428/// routed to the `*_for_nested` variants of those two trait methods.
429///
430/// Built from the definitions the backend returned rather than from
431/// `analyzed.nested_structs`, so a backend that degraded the nested column
432/// to `json` or `json_array` does not emit an otherwise unused enum.
433#[derive(Debug, Default)]
434struct NestedTypeRefs {
435    enums: ahash::AHashSet<String>,
436    composites: ahash::AHashSet<String>,
437}
438
439impl NestedTypeRefs {
440    fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
441        let mut refs = Self::default();
442        for nested in &analyzed.nested_structs {
443            if !supported.iter().any(|def| def.name == nested.name) {
444                continue;
445            }
446            for field in &nested.fields {
447                let unwrapped = unwrap_containers(&field.neutral_type);
448                if let Some(name) = unwrapped.strip_prefix("enum::") {
449                    refs.enums.insert(name.to_string());
450                } else if let Some(name) = unwrapped.strip_prefix("composite::") {
451                    refs.composites.insert(name.to_string());
452                }
453            }
454        }
455        refs
456    }
457}
458
459/// Order composite definitions so a composite is emitted *after* every
460/// composite its own fields name.
461///
462/// `analyzed.composites` arrives in the analyzer's breadth-first discovery
463/// order: types reached from the query's columns go in first, and a type
464/// reached only through another composite's field list is appended behind the
465/// composite that named it. That is exactly backwards for any target language
466/// where a type reference is resolved at definition time. `CREATE TYPE outer
467/// AS (inner_field inner_type)`, selected as a column, yields
468/// `[outer, inner]` -- and python's `@dataclass` annotations are evaluated
469/// when the class body runs, so the emitted module raised `NameError: name
470/// 'Inner' is not defined` on import. The languages whose declarations hoist
471/// (TypeScript types, Java/Kotlin classes, Rust items) never noticed, which is
472/// why this survived: it is invisible unless the generated file is executed.
473///
474/// A post-order depth-first walk puts dependencies first. Ties and unreachable
475/// entries keep their original relative order, so this only ever moves a
476/// composite earlier than something that references it -- it is a no-op for
477/// the flat case that has no nested composites at all.
478///
479/// PostgreSQL rejects a genuine composite cycle at `CREATE TYPE` time, but the
480/// `visiting` set does not assume that has been enforced against every catalog
481/// this runs against: a cycle degrades to "emit in discovery order" rather
482/// than recursing forever.
483fn composites_in_dependency_order(composites: &[CompositeInfo]) -> Vec<&CompositeInfo> {
484    let by_name: ahash::AHashMap<&str, &CompositeInfo> = composites.iter().map(|c| (c.sql_name.as_str(), c)).collect();
485    let mut ordered: Vec<&CompositeInfo> = Vec::with_capacity(composites.len());
486    let mut emitted: ahash::AHashSet<&str> = ahash::AHashSet::new();
487    let mut visiting: ahash::AHashSet<&str> = ahash::AHashSet::new();
488
489    fn visit<'a>(
490        comp: &'a CompositeInfo,
491        by_name: &ahash::AHashMap<&'a str, &'a CompositeInfo>,
492        emitted: &mut ahash::AHashSet<&'a str>,
493        visiting: &mut ahash::AHashSet<&'a str>,
494        ordered: &mut Vec<&'a CompositeInfo>,
495    ) {
496        let name = comp.sql_name.as_str();
497        if emitted.contains(name) || !visiting.insert(name) {
498            return;
499        }
500        for field in &comp.fields {
501            if let Some(dep) = unwrap_containers(&field.neutral_type).strip_prefix("composite::")
502                && let Some(dep) = by_name.get(dep)
503            {
504                visit(dep, by_name, emitted, visiting, ordered);
505            }
506        }
507        visiting.remove(name);
508        if emitted.insert(name) {
509            ordered.push(comp);
510        }
511    }
512
513    for comp in composites {
514        visit(comp, &by_name, &mut emitted, &mut visiting, &mut ordered);
515    }
516    ordered
517}
518
519/// Strip any number of `array<...>` / `nullable<...>` container wrappers
520/// from a neutral type, returning the innermost type.
521///
522/// These are the only two generic container names the analyzer emits around
523/// an arbitrary inner neutral type (see `sql_type_to_neutral` and the outer
524/// -join nullable-widening path in `scythe-core`); `json_nested<...>` is
525/// deliberately not one of them -- see [`nested_struct_shape`]'s doc comment
526/// -- so it is never unwrapped here.
527///
528/// The single point every "is `enum::x` / `composite::x` reachable"
529/// question in this file must go through: matching only the bare
530/// `"enum::x"` / `"composite::x"` string, as every call site here used to,
531/// misses `array<enum::x>` and `array<nullable<composite::x>>` entirely --
532/// the type is inferred correctly by `resolve.rs` -> `types.rs`, which
533/// *does* recurse into containers, but the definition-emission side stayed
534/// an exact-match check, so the row struct referenced a type whose
535/// definition was never emitted (#187).
536pub(crate) fn unwrap_containers(neutral: &str) -> &str {
537    let mut current = neutral;
538    loop {
539        if let Some(inner) = current.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
540            current = inner.trim();
541        } else if let Some(inner) = current.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
542            current = inner.trim();
543        } else {
544            return current;
545        }
546    }
547}
548
549/// Whether a query's top-level columns or params reference `sql_name` under
550/// `prefix` (`"enum::"` or `"composite::"`), directly or through any number
551/// of `array<...>` / `nullable<...>` wrappers.
552///
553/// Matches the `prefix + sql_name` neutral type after [`unwrap_containers`],
554/// the same rule `generate_enum_defs_via_backend` uses, so "reachable from
555/// columns" means the same thing in both places.
556fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
557    let neutral = format!("{prefix}{sql_name}");
558    analyzed
559        .columns
560        .iter()
561        .any(|col| unwrap_containers(&col.neutral_type) == neutral)
562        || analyzed
563            .params
564            .iter()
565            .any(|param| unwrap_containers(&param.neutral_type) == neutral)
566}
567
568/// Every composite `sql_name` this query's generated output actually needs a
569/// definition for: reached directly from a column/param neutral type, from a
570/// nested-aggregate struct's field list (`nested_refs`), or transitively
571/// through another reachable composite's own field list.
572///
573/// `CREATE TYPE outer AS (inner_field inner_type)` selected only as `outer`
574/// needs `inner`'s definition too, but neither `type_referenced_by_columns`
575/// nor `NestedTypeRefs::collect` looks inside a composite's own fields --
576/// without this walk `inner` stays in `analyzed.composites` (scythe-core
577/// recurses to collect it) with its definition silently skipped here
578/// (unfiled, found alongside the JVM composite `fromText` fix in ddb7bb00).
579/// `analyzed.composites` already holds at most one entry per name (deduped by
580/// the analyzer), so this only decides which of those entries are reachable
581/// -- it does not need its own diamond guard beyond that.
582fn reachable_composite_names<'a>(
583    analyzed: &'a AnalyzedQuery,
584    nested_refs: &NestedTypeRefs,
585) -> ahash::AHashSet<&'a str> {
586    let mut reachable: ahash::AHashSet<&str> = ahash::AHashSet::new();
587    let mut worklist: Vec<&str> = analyzed
588        .composites
589        .iter()
590        .filter(|comp| {
591            nested_refs.composites.contains(comp.sql_name.as_str())
592                || type_referenced_by_columns(analyzed, "composite::", &comp.sql_name)
593        })
594        .map(|comp| comp.sql_name.as_str())
595        .collect();
596
597    while let Some(name) = worklist.pop() {
598        if !reachable.insert(name) {
599            continue;
600        }
601        let Some(comp) = analyzed.composites.iter().find(|c| c.sql_name == name) else {
602            continue;
603        };
604        for field in &comp.fields {
605            if let Some(nested_name) = unwrap_containers(&field.neutral_type).strip_prefix("composite::") {
606                worklist.push(nested_name);
607            }
608        }
609    }
610    reachable
611}
612
613/// Whether `sql_name` is reachable from a nested-aggregate struct the
614/// backend actually emitted a definition for.
615///
616/// The `supported` filter keeps degraded output self-contained: a type
617/// reachable only through a nested struct rewritten to `json` or
618/// `json_array` must not be emitted at all.
619pub fn nested_type_is_emitted(
620    analyzed: &AnalyzedQuery,
621    supported: &[NestedStructDef],
622    prefix: &str,
623    sql_name: &str,
624) -> bool {
625    let neutral = format!("{prefix}{sql_name}");
626    analyzed
627        .nested_structs
628        .iter()
629        .filter(|nested| supported.iter().any(|def| def.name == nested.name))
630        .any(|nested| {
631            nested
632                .fields
633                .iter()
634                .any(|field| unwrap_containers(&field.neutral_type) == neutral)
635        })
636}
637
638/// Generate enum definitions via the backend trait.
639///
640/// `declared_struct_name` is the query's own row/model type name when this
641/// query actually declares one (`None` for `:grouped`, `:exec`, and any
642/// command with no row struct at all -- see the call site) -- checked
643/// alongside every enum name so a collision with the query's own type is
644/// caught the same way two enums colliding with each other is (#136).
645fn generate_enum_defs_via_backend(
646    analyzed: &AnalyzedQuery,
647    backend: &dyn CodegenBackend,
648    nested_refs: &NestedTypeRefs,
649    declared_struct_name: Option<&str>,
650) -> Result<String, ScytheError> {
651    use ahash::AHashSet;
652    use std::fmt::Write;
653
654    let naming = &backend.manifest().naming;
655
656    let enum_sources: Vec<&str> = analyzed
657        .columns
658        .iter()
659        .filter_map(|col| unwrap_containers(&col.neutral_type).strip_prefix("enum::"))
660        .chain(
661            analyzed
662                .params
663                .iter()
664                .filter_map(|p| unwrap_containers(&p.neutral_type).strip_prefix("enum::")),
665        )
666        .chain(nested_refs.enums.iter().map(String::as_str))
667        .collect();
668
669    let mut seen_enums: AHashSet<&str> = AHashSet::new();
670    let unique_sources: Vec<&str> = enum_sources
671        .into_iter()
672        .filter(|name| seen_enums.insert(*name))
673        .collect();
674
675    // Every enum this query renders, paired with the type name it generates
676    // under `struct_case`, plus the query's own row/model type when it
677    // declares one -- checked against each other before anything is
678    // rendered, the same `ErrorCode::DuplicateAlias` mechanism
679    // `resolve::check_field_name_collisions` applies to columns and params.
680    let generated_names: Vec<(&str, String)> = unique_sources
681        .iter()
682        .map(|sql_name| (*sql_name, scythe_backend::naming::enum_type_name(sql_name, naming)))
683        .collect();
684    let query_type_entry = declared_struct_name.map(|name| ("<query row/model type>", name.to_string()));
685    resolve::check_type_name_collisions(
686        generated_names
687            .iter()
688            .chain(query_type_entry.iter())
689            .map(|(source, name)| (*source, name.as_str())),
690        "enum and query type names",
691    )?;
692
693    let mut out = String::new();
694    for sql_name in unique_sources {
695        if !out.is_empty() {
696            let _ = writeln!(out);
697        }
698
699        let stub_info;
700        let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
701            Some(info) => info,
702            None => {
703                stub_info = EnumInfo {
704                    sql_name: sql_name.to_string(),
705                    values: vec![],
706                };
707                &stub_info
708            }
709        };
710        // Checked once per enum, before either rendering path below: both
711        // `generate_enum_def` and `generate_enum_def_for_nested` iterate the
712        // same `enum_info.values`, so a collision would otherwise reach
713        // whichever one runs (#136).
714        let variant_names: Vec<(&str, String)> = enum_info
715            .values
716            .iter()
717            .map(|value| (value.as_str(), scythe_backend::naming::enum_variant_name(value, naming)))
718            .collect();
719        resolve::check_enum_variant_collisions(
720            variant_names.iter().map(|(source, name)| (*source, name.as_str())),
721            sql_name,
722        )?;
723
724        if nested_refs.enums.contains(sql_name) {
725            out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
726        } else {
727            out.push_str(&backend.generate_enum_def(enum_info)?);
728        }
729    }
730
731    Ok(out)
732}
733
734/// The decomposed form of a `json_nested<...>` neutral type.
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
736pub(crate) struct NestedColumnShape<'a> {
737    /// `json_agg` produces an array of objects; `row_to_json` a single one.
738    pub(crate) is_array: bool,
739    /// Only ever true for an array: `json_agg` over an outer join emits
740    /// `[null]` when nothing matched, so the *element* is optional. See
741    /// `Analyzer::infer_nested_aggregate_type`.
742    pub(crate) element_nullable: bool,
743    /// PascalCase name of the generated struct.
744    pub(crate) name: &'a str,
745}
746
747/// Decompose a `json_nested<...>` neutral type, or `None` for anything else.
748///
749/// `json_nested` is the container the analyzer emits for a nested aggregate,
750/// deliberately distinct from `json_typed`, which is what a user's own
751/// `@json` annotation produces (`analyzer/mod.rs`). Keeping them apart is
752/// what lets this function -- and every backend decision keyed off it --
753/// mean "a struct *scythe* synthesized and knows the field-by-field shape
754/// of", rather than "some type the user named". Matching `json_typed` here
755/// would make a user's `@json EventData` mapping (which may be a
756/// `TypedDict`, a JSON array shape, or not constructible from a mapping at
757/// all) take the nested-construction path.
758///
759/// ```text
760/// "json_nested<array<nullable<GetUserPostsRowPosts>>>" -> array, nullable elements
761/// "json_nested<array<GetUserPostsRowPosts>>"           -> array, non-null elements
762/// "json_nested<GetPostAsJsonRowPost>"                  -> single object
763/// "json_typed<EventData>", "int32", "json", "array<int32>" -> None
764/// ```
765pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
766    let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
767    let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
768        return Some(NestedColumnShape {
769            is_array: false,
770            element_nullable: false,
771            name: inner,
772        });
773    };
774    match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
775        Some(name) => Some(NestedColumnShape {
776            is_array: true,
777            element_nullable: true,
778            name,
779        }),
780        None => Some(NestedColumnShape {
781            is_array: true,
782            element_nullable: false,
783            name: element,
784        }),
785    }
786}
787
788/// Degradation pass for nested-aggregate columns (`json_agg(o.*)`,
789/// `row_to_json(u.*)`). Must run before `resolve::resolve_columns` -- every
790/// caller that resolves `AnalyzedColumn`s into a backend's types needs this,
791/// not just [`generate_with_backend_and_overrides`]: `scythe-cli`'s RBS
792/// signature generation (`generate_rbs_if_supported`) calls
793/// `resolve::resolve_columns` directly on a second, independent path, so it
794/// must run this pass too or it silently bypasses the byte-identity
795/// guarantee for any backend that hasn't opted in.
796///
797/// For each entry in `nested_structs`, asks the backend whether it opts in
798/// via [`CodegenBackend::generate_nested_struct_def`]:
799/// - `Ok(Some(def))`: the struct is supported. Its definition is collected
800///   to append to the backend's output (the same channel
801///   `generate_composite_def` output goes through), and columns
802///   referencing it are left as `json_nested<...>`.
803/// - `Ok(None)` (the default): not supported. Every column referencing it
804///   is rewritten to plain `json`, unless an array-shaped result targets a
805///   manifest that explicitly defines the `json_array` scalar marker. That
806///   marker means "one JSON document whose top level is an array"; it is
807///   deliberately distinct from `array<json>`, which means a SQL `json[]`
808///   column and may select a typed SQL-array reader.
809/// - `Err(_)`: a genuine failure, propagated rather than degraded.
810///
811/// Returns the (possibly rewritten) columns, one [`NestedStructDef`] per
812/// struct the backend supports, and one [`NestedStructDegradation`] per
813/// column that was rewritten instead (GH #147). The third element is the
814/// only channel that says a rewrite happened at all: before it existed, a
815/// column silently lost its structured shape and nothing -- not the return
816/// value, not a log line -- told the caller. Every caller is expected to
817/// surface it; [`GeneratedCode::degraded_nested_structs`] is where
818/// [`generate_with_backend_and_overrides`] threads it for its own calls
819/// below, and any other direct caller must do the same rather than drop it.
820///
821/// Skip calling this entirely when `nested_structs` is empty (the common
822/// case) to keep that path zero-copy -- see the callers in this file for
823/// the pattern.
824/// What [`degrade_unsupported_nested_structs`] returns: the columns after any
825/// rewrite, the struct definitions the backend did produce, and one record per
826/// column that lost its shape (GH #147).
827pub type DegradedNestedStructs = (Vec<AnalyzedColumn>, Vec<NestedStructDef>, Vec<NestedStructDegradation>);
828
829pub fn degrade_unsupported_nested_structs(
830    columns: &[AnalyzedColumn],
831    nested_structs: &[NestedStructInfo],
832    backend: &dyn CodegenBackend,
833) -> Result<DegradedNestedStructs, ScytheError> {
834    if nested_structs.is_empty() {
835        return Ok((columns.to_vec(), Vec::new(), Vec::new()));
836    }
837
838    use ahash::AHashSet;
839
840    let mut unsupported: AHashSet<String> = AHashSet::new();
841    let mut defs: Vec<NestedStructDef> = Vec::new();
842    for nested in nested_structs {
843        match backend.generate_nested_struct_def(nested)? {
844            Some(def) => defs.push(NestedStructDef {
845                name: nested.name.clone(),
846                code: def,
847            }),
848            None => {
849                unsupported.insert(to_pascal_case(&nested.name).into_owned());
850            }
851        }
852    }
853
854    if unsupported.is_empty() {
855        return Ok((columns.to_vec(), defs, Vec::new()));
856    }
857
858    let supports_json_array = backend.manifest().types.scalars.contains_key("json_array");
859    let backend_name = backend.name().to_string();
860
861    let mut degradations: Vec<NestedStructDegradation> = Vec::new();
862    let degraded = columns
863        .iter()
864        .cloned()
865        .map(|mut col| {
866            if let Some(shape) = nested_struct_shape(&col.neutral_type)
867                && unsupported.contains(shape.name)
868            {
869                let fallback_type = if shape.is_array && supports_json_array {
870                    "json_array"
871                } else {
872                    "json"
873                };
874                degradations.push(NestedStructDegradation {
875                    column: col.name.clone(),
876                    struct_name: shape.name.to_string(),
877                    fallback_type,
878                    backend: backend_name.clone(),
879                });
880                col.neutral_type = fallback_type.to_string();
881            }
882            col
883        })
884        .collect();
885
886    Ok((degraded, defs, degradations))
887}
888
889/// Backward-compatible: generate code using the default sqlx backend.
890pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
891    let backend = get_backend("rust-sqlx", "postgresql")?;
892    generate_with_backend(analyzed, &*backend)
893}
894
895/// Generate a single enum definition using a specific backend.
896///
897/// `reachable_from_nested` selects [`CodegenBackend::generate_enum_def_for_nested`]
898/// instead of the plain form. A file writer that emits enums once for the
899/// whole file must pass `true` when *any* query in that file reaches the
900/// enum from a nested-aggregate field: one definition serves every use, and
901/// the nested-capable form is a superset (extra derives), so widening is
902/// safe where narrowing is not.
903pub fn generate_single_enum_def_with_backend(
904    enum_info: &EnumInfo,
905    backend: &dyn CodegenBackend,
906    reachable_from_nested: bool,
907) -> Result<String, ScytheError> {
908    if reachable_from_nested {
909        backend.generate_enum_def_for_nested(enum_info)
910    } else {
911        backend.generate_enum_def(enum_info)
912    }
913}
914
915/// Backward-compatible: generate a single enum definition (sqlx backend).
916/// Uses the manifest directly for backward compatibility with existing callers.
917pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
918    use scythe_backend::naming::{enum_type_name, enum_variant_name};
919    use std::fmt::Write;
920
921    let mut out = String::with_capacity(256);
922    let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
923
924    let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
925    let _ = writeln!(
926        out,
927        "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
928        enum_info.sql_name
929    );
930    let _ = writeln!(out, "pub enum {type_name} {{");
931
932    for value in &enum_info.values {
933        let variant = enum_variant_name(value, &manifest.naming);
934        let _ = writeln!(out, "    {variant},");
935    }
936
937    let _ = write!(out, "}}");
938    out
939}
940
941/// Backward-compatible: load the default sqlx manifest.
942pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
943    let b = backends::sqlx::SqlxBackend::new("postgresql")?;
944    Ok(b.manifest().clone())
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
951    use scythe_core::parser::QueryCommand;
952
953    fn make_query(
954        name: &str,
955        command: QueryCommand,
956        sql: &str,
957        columns: Vec<AnalyzedColumn>,
958        params: Vec<AnalyzedParam>,
959    ) -> AnalyzedQuery {
960        AnalyzedQuery::build(|aq| {
961            aq.name = name.to_string();
962            aq.command = command;
963            aq.sql = sql.to_string();
964            aq.columns = columns;
965            aq.params = params;
966            aq.deprecated = None;
967            aq.source_table = None;
968            aq.composites = Vec::new();
969            aq.enums = Vec::new();
970            aq.optional_params = Vec::new();
971            aq.group_by = None;
972            aq.custom = Vec::new();
973        })
974    }
975
976    fn test_naming() -> NamingConfig {
977        NamingConfig {
978            struct_case: "PascalCase".to_string(),
979            fn_case: "snake_case".to_string(),
980            enum_variant_case: "PascalCase".to_string(),
981            row_suffix: "Row".to_string(),
982            field_case: "snake_case".to_string(),
983            reserved: Vec::new(),
984            reserved_bindings: Vec::new(),
985            sanitize_field_names: false,
986        }
987    }
988
989    /// Regression for board #199: a schema-qualified table's `.` must not
990    /// reach the generated model struct name, exactly as #136 already fixed
991    /// for `enum_type_name`. Before the fix, `model_struct_name("app.widgets",
992    /// ..)` under `struct_case = "PascalCase"` returned `"App.widget"` --
993    /// `pub struct App.widget` does not parse in any target language, since
994    /// neither `singularize` nor `apply_case` ever removes a character an
995    /// identifier cannot hold.
996    #[test]
997    fn model_struct_name_sanitizes_a_schema_qualified_dot() {
998        assert_eq!(model_struct_name("app.widgets", &test_naming()), "AppWidget");
999    }
1000
1001    #[test]
1002    fn test_generate_select_many() {
1003        let query = make_query(
1004            "ListUsers",
1005            QueryCommand::Many,
1006            "SELECT id, name, email FROM users",
1007            vec![
1008                AnalyzedColumn {
1009                    name: "id".to_string(),
1010                    neutral_type: "int32".to_string(),
1011                    nullable: false,
1012                    ..Default::default()
1013                },
1014                AnalyzedColumn {
1015                    name: "name".to_string(),
1016                    neutral_type: "string".to_string(),
1017                    nullable: false,
1018                    ..Default::default()
1019                },
1020                AnalyzedColumn {
1021                    name: "email".to_string(),
1022                    neutral_type: "string".to_string(),
1023                    nullable: true,
1024                    ..Default::default()
1025                },
1026            ],
1027            vec![],
1028        );
1029
1030        let result = generate(&query).unwrap();
1031
1032        let row_struct = result.row_struct.unwrap();
1033        assert!(row_struct.contains("pub struct ListUsersRow"));
1034        assert!(row_struct.contains("pub id: i32"));
1035        assert!(row_struct.contains("pub name: String"));
1036        assert!(row_struct.contains("pub email: Option<String>"));
1037
1038        let query_fn = result.query_fn.unwrap();
1039        assert!(query_fn.contains("pub async fn list_users("));
1040        assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
1041        assert!(query_fn.contains(".fetch_all(pool)"));
1042    }
1043
1044    #[test]
1045    fn test_generate_select_one_with_param() {
1046        let query = make_query(
1047            "GetUser",
1048            QueryCommand::One,
1049            "SELECT id, name FROM users WHERE id = $1",
1050            vec![
1051                AnalyzedColumn {
1052                    name: "id".to_string(),
1053                    neutral_type: "int32".to_string(),
1054                    nullable: false,
1055                    ..Default::default()
1056                },
1057                AnalyzedColumn {
1058                    name: "name".to_string(),
1059                    neutral_type: "string".to_string(),
1060                    nullable: false,
1061                    ..Default::default()
1062                },
1063            ],
1064            vec![AnalyzedParam {
1065                name: "id".to_string(),
1066                neutral_type: "int32".to_string(),
1067                nullable: false,
1068                position: 1,
1069                source_relation: None,
1070            }],
1071        );
1072
1073        let result = generate(&query).unwrap();
1074
1075        let query_fn = result.query_fn.unwrap();
1076        assert!(query_fn.contains("pub async fn get_user("));
1077        assert!(query_fn.contains("id: i32"));
1078        assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
1079        assert!(query_fn.contains(".fetch_one(pool)"));
1080    }
1081
1082    #[test]
1083    fn test_generate_exec() {
1084        let query = make_query(
1085            "DeleteUser",
1086            QueryCommand::Exec,
1087            "DELETE FROM users WHERE id = $1",
1088            vec![],
1089            vec![AnalyzedParam {
1090                name: "id".to_string(),
1091                neutral_type: "int32".to_string(),
1092                nullable: false,
1093                position: 1,
1094                source_relation: None,
1095            }],
1096        );
1097
1098        let result = generate(&query).unwrap();
1099
1100        assert!(result.row_struct.is_none());
1101
1102        let query_fn = result.query_fn.unwrap();
1103        assert!(query_fn.contains("pub async fn delete_user("));
1104        assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
1105        assert!(query_fn.contains(".execute(pool)"));
1106    }
1107
1108    #[test]
1109    fn test_generate_with_enum_column() {
1110        let query = make_query(
1111            "GetUserStatus",
1112            QueryCommand::One,
1113            "SELECT id, status FROM users WHERE id = $1",
1114            vec![
1115                AnalyzedColumn {
1116                    name: "id".to_string(),
1117                    neutral_type: "int32".to_string(),
1118                    nullable: false,
1119                    ..Default::default()
1120                },
1121                AnalyzedColumn {
1122                    name: "status".to_string(),
1123                    neutral_type: "enum::user_status".to_string(),
1124                    nullable: false,
1125                    ..Default::default()
1126                },
1127            ],
1128            vec![AnalyzedParam {
1129                name: "id".to_string(),
1130                neutral_type: "int32".to_string(),
1131                nullable: false,
1132                position: 1,
1133                source_relation: None,
1134            }],
1135        );
1136
1137        let result = generate(&query).unwrap();
1138
1139        assert!(result.enum_def.is_some());
1140        let enum_def = result.enum_def.unwrap();
1141        assert!(enum_def.contains("pub enum UserStatus"));
1142        assert!(enum_def.contains("type_name = \"user_status\""));
1143
1144        let row_struct = result.row_struct.unwrap();
1145        assert!(row_struct.contains("pub status: UserStatus"));
1146    }
1147
1148    #[test]
1149    fn test_singularize_basic() {
1150        assert_eq!(singularize("users"), "user");
1151        assert_eq!(singularize("orders"), "order");
1152        assert_eq!(singularize("posts"), "post");
1153    }
1154
1155    #[test]
1156    fn test_singularize_ies() {
1157        assert_eq!(singularize("categories"), "category");
1158        assert_eq!(singularize("entries"), "entry");
1159    }
1160
1161    #[test]
1162    fn test_singularize_sses() {
1163        assert_eq!(singularize("addresses"), "address");
1164        assert_eq!(singularize("classes"), "class");
1165    }
1166
1167    #[test]
1168    fn test_singularize_no_change() {
1169        assert_eq!(singularize("status"), "statu");
1170        assert_eq!(singularize("boss"), "boss");
1171        assert_eq!(singularize("address"), "address");
1172    }
1173
1174    #[test]
1175    fn test_singularize_shes_ches_xes() {
1176        assert_eq!(singularize("batches"), "batch");
1177        assert_eq!(singularize("boxes"), "box");
1178        assert_eq!(singularize("wishes"), "wish");
1179    }
1180
1181    fn make_grouped_query() -> AnalyzedQuery {
1182        let parent_cols = vec![
1183            AnalyzedColumn {
1184                name: "id".to_string(),
1185                neutral_type: "int32".to_string(),
1186                nullable: false,
1187                ..Default::default()
1188            },
1189            AnalyzedColumn {
1190                name: "name".to_string(),
1191                neutral_type: "string".to_string(),
1192                nullable: false,
1193                ..Default::default()
1194            },
1195            AnalyzedColumn {
1196                name: "email".to_string(),
1197                neutral_type: "string".to_string(),
1198                nullable: false,
1199                ..Default::default()
1200            },
1201        ];
1202        let child_cols = vec![
1203            AnalyzedColumn {
1204                name: "order_id".to_string(),
1205                neutral_type: "int32".to_string(),
1206                nullable: false,
1207                ..Default::default()
1208            },
1209            AnalyzedColumn {
1210                name: "total".to_string(),
1211                neutral_type: "decimal".to_string(),
1212                nullable: true,
1213                ..Default::default()
1214            },
1215            AnalyzedColumn {
1216                name: "order_date".to_string(),
1217                neutral_type: "datetime".to_string(),
1218                nullable: false,
1219                ..Default::default()
1220            },
1221        ];
1222        let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
1223
1224        AnalyzedQuery::build(|aq| {
1225            aq.name = "GetUsersWithOrders".to_string();
1226            aq.command = QueryCommand::Grouped;
1227            aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
1228                  SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
1229                  FROM users u\n\
1230                  JOIN orders o ON o.user_id = u.id"
1231                .to_string();
1232            aq.columns = all_cols;
1233            aq.params = vec![];
1234            aq.deprecated = None;
1235            aq.source_table = None;
1236            aq.composites = vec![];
1237            aq.enums = vec![];
1238            aq.optional_params = vec![];
1239            aq.group_by = Some(GroupByConfig {
1240                table: "users".to_string(),
1241                key_column: "id".to_string(),
1242                parent_columns: parent_cols,
1243                child_columns: child_cols,
1244            });
1245            aq.custom = vec![];
1246        })
1247    }
1248
1249    #[test]
1250    fn test_grouped_sqlx_structs() {
1251        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1252        let query = make_grouped_query();
1253        let result = generate_with_backend(&query, &*backend).unwrap();
1254
1255        let row_struct = result.row_struct.as_deref().unwrap();
1256        assert!(
1257            row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
1258            "missing child struct; got:\n{row_struct}"
1259        );
1260        assert!(
1261            row_struct.contains("pub order_id: i32"),
1262            "child struct missing order_id field; got:\n{row_struct}"
1263        );
1264        assert!(
1265            row_struct.contains("pub struct GetUsersWithOrdersRow"),
1266            "missing parent struct; got:\n{row_struct}"
1267        );
1268        assert!(
1269            row_struct.contains("pub id: i32"),
1270            "parent struct missing id field; got:\n{row_struct}"
1271        );
1272        assert!(
1273            row_struct.contains("pub name: String"),
1274            "parent struct missing name field; got:\n{row_struct}"
1275        );
1276        assert!(
1277            row_struct.contains("pub email: String"),
1278            "parent struct missing email field; got:\n{row_struct}"
1279        );
1280        assert!(
1281            row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
1282            "parent struct missing children field; got:\n{row_struct}"
1283        );
1284        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1285        let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
1286        assert!(
1287            child_pos < parent_pos,
1288            "child struct must be defined before parent struct"
1289        );
1290
1291        assert!(
1292            result.model_struct.is_none(),
1293            "grouped should not produce a model_struct"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_grouped_sqlx_query_fn() {
1299        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1300        let query = make_grouped_query();
1301        let result = generate_with_backend(&query, &*backend).unwrap();
1302
1303        let query_fn = result.query_fn.as_deref().unwrap();
1304        assert!(
1305            query_fn.contains("pub async fn get_users_with_orders("),
1306            "missing fn; got:\n{query_fn}"
1307        );
1308        assert!(
1309            query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
1310            "wrong return type; got:\n{query_fn}"
1311        );
1312        assert!(
1313            query_fn.contains("sqlx::query!("),
1314            "grouped fn must use sqlx::query!; got:\n{query_fn}"
1315        );
1316        assert!(
1317            query_fn.contains("GetUsersWithOrdersChildRow {"),
1318            "fn must construct child struct; got:\n{query_fn}"
1319        );
1320        assert!(
1321            query_fn.contains("children: vec![child]"),
1322            "fn must initialize children vec; got:\n{query_fn}"
1323        );
1324        assert!(
1325            query_fn.contains("parent.children.push(child)"),
1326            "fn must fold child into existing parent; got:\n{query_fn}"
1327        );
1328        assert!(
1329            query_fn.contains("Ok(result)"),
1330            "fn must return result; got:\n{query_fn}"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_grouped_python_asyncpg_structs() {
1336        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1337        let query = make_grouped_query();
1338        let result = generate_with_backend(&query, &*backend).unwrap();
1339
1340        let row_struct = result.row_struct.as_deref().unwrap();
1341        assert!(
1342            row_struct.contains("class GetUsersWithOrdersChildRow"),
1343            "missing child class; got:\n{row_struct}"
1344        );
1345        assert!(
1346            row_struct.contains("order_id: int"),
1347            "child class missing order_id field; got:\n{row_struct}"
1348        );
1349        assert!(
1350            row_struct.contains("class GetUsersWithOrdersRow"),
1351            "missing parent class; got:\n{row_struct}"
1352        );
1353        assert!(
1354            row_struct.contains("id: int"),
1355            "parent class missing id field; got:\n{row_struct}"
1356        );
1357        assert!(
1358            row_struct.contains("name: str"),
1359            "parent class missing name field; got:\n{row_struct}"
1360        );
1361        assert!(
1362            row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
1363            "parent class missing children field; got:\n{row_struct}"
1364        );
1365        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
1366        let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
1367        assert!(
1368            child_pos < parent_pos,
1369            "child class must be defined before parent class"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_grouped_python_asyncpg_query_fn() {
1375        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1376        let query = make_grouped_query();
1377        let result = generate_with_backend(&query, &*backend).unwrap();
1378
1379        let query_fn = result.query_fn.as_deref().unwrap();
1380        assert!(
1381            query_fn.contains("async def get_users_with_orders(conn: Connection)"),
1382            "missing fn; got:\n{query_fn}"
1383        );
1384        assert!(
1385            query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
1386            "wrong return type; got:\n{query_fn}"
1387        );
1388        assert!(
1389            query_fn.contains("conn.fetch("),
1390            "fn must use conn.fetch; got:\n{query_fn}"
1391        );
1392        assert!(
1393            query_fn.contains("GetUsersWithOrdersChildRow("),
1394            "fn must construct child class; got:\n{query_fn}"
1395        );
1396        assert!(
1397            query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
1398            "fn must construct parent with **kwargs; got:\n{query_fn}"
1399        );
1400        assert!(
1401            query_fn.contains("_index"),
1402            "fn must use index dict for O(1) lookup; got:\n{query_fn}"
1403        );
1404    }
1405
1406    /// Minimal backend that implements only the required trait methods and
1407    /// leaves the grouped methods as their default (erroring) impl. Every
1408    /// shipped backend now supports grouped codegen, so the "not yet supported"
1409    /// path can only be exercised by a backend that has not opted in — this stub
1410    /// stands in for such a backend so the contract stays covered.
1411    struct StubBackend {
1412        manifest: BackendManifest,
1413    }
1414
1415    impl CodegenBackend for StubBackend {
1416        fn name(&self) -> &str {
1417            "stub-backend"
1418        }
1419        fn manifest(&self) -> &BackendManifest {
1420            &self.manifest
1421        }
1422        fn manifest_mut(&mut self) -> &mut BackendManifest {
1423            &mut self.manifest
1424        }
1425        fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1426            Ok(String::new())
1427        }
1428        fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1429            Ok(String::new())
1430        }
1431        fn generate_query_fn(
1432            &self,
1433            _analyzed: &AnalyzedQuery,
1434            _struct_name: &str,
1435            _columns: &[ResolvedColumn],
1436            _params: &[ResolvedParam],
1437        ) -> Result<String, ScytheError> {
1438            Ok(String::new())
1439        }
1440        fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1441            Ok(String::new())
1442        }
1443        fn generate_composite_def(
1444            &self,
1445            _composite: &scythe_core::analyzer::CompositeInfo,
1446        ) -> Result<String, ScytheError> {
1447            Ok(String::new())
1448        }
1449    }
1450
1451    #[test]
1452    fn test_grouped_unsupported_backend_returns_clear_error() {
1453        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1454        let backend = StubBackend { manifest };
1455        let query = make_grouped_query();
1456        let result = generate_with_backend(&query, &backend);
1457        assert!(
1458            result.is_err(),
1459            "stub backend should return an error for grouped queries"
1460        );
1461        let err = result.unwrap_err();
1462        assert!(
1463            err.message.contains("not yet supported"),
1464            "error should contain 'not yet supported', got: {}",
1465            err.message
1466        );
1467        assert!(
1468            err.message.contains("stub-backend"),
1469            "error should name the backend, got: {}",
1470            err.message
1471        );
1472    }
1473
1474    #[test]
1475    fn test_tokio_postgres_backend_basic() {
1476        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1477
1478        let query = make_query(
1479            "ListUsers",
1480            QueryCommand::Many,
1481            "SELECT id, name FROM users",
1482            vec![
1483                AnalyzedColumn {
1484                    name: "id".to_string(),
1485                    neutral_type: "int32".to_string(),
1486                    nullable: false,
1487                    ..Default::default()
1488                },
1489                AnalyzedColumn {
1490                    name: "name".to_string(),
1491                    neutral_type: "string".to_string(),
1492                    nullable: false,
1493                    ..Default::default()
1494                },
1495            ],
1496            vec![],
1497        );
1498
1499        let result = generate_with_backend(&query, &*backend).unwrap();
1500
1501        let row_struct = result.row_struct.unwrap();
1502        assert!(row_struct.contains("pub struct ListUsersRow"));
1503        assert!(row_struct.contains("pub id: i32"));
1504        assert!(row_struct.contains("pub name: String"));
1505        assert!(row_struct.contains("from_row"));
1506        assert!(row_struct.contains("tokio_postgres::Row"));
1507        assert!(!row_struct.contains("sqlx"));
1508
1509        let query_fn = result.query_fn.unwrap();
1510        assert!(query_fn.contains("pub async fn list_users("));
1511        assert!(query_fn.contains("tokio_postgres::GenericClient"));
1512        assert!(query_fn.contains("tokio_postgres::Error"));
1513        assert!(!query_fn.contains("sqlx"));
1514    }
1515
1516    #[test]
1517    fn test_tokio_postgres_enum() {
1518        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1519
1520        let enum_info = scythe_core::analyzer::EnumInfo {
1521            sql_name: "user_status".to_string(),
1522            values: vec!["active".to_string(), "inactive".to_string()],
1523        };
1524
1525        let def = backend.generate_enum_def(&enum_info).unwrap();
1526        assert!(def.contains("pub enum UserStatus"));
1527        assert!(def.contains("Active"));
1528        assert!(def.contains("Inactive"));
1529        assert!(def.contains("impl std::fmt::Display"));
1530        assert!(def.contains("impl std::str::FromStr"));
1531        assert!(!def.contains("sqlx"));
1532    }
1533
1534    #[test]
1535    fn test_sql_with_least_coalesce_sum_preserved() {
1536        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1537
1538        let query = make_query(
1539            "GetBillingAggregates",
1540            QueryCommand::One,
1541            "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
1542            vec![AnalyzedColumn {
1543                name: "aggregated_free_pages".to_string(),
1544                neutral_type: "int64".to_string(),
1545                nullable: false,
1546                ..Default::default()
1547            }],
1548            vec![AnalyzedParam {
1549                name: "customer_id".to_string(),
1550                neutral_type: "uuid".to_string(),
1551                nullable: false,
1552                position: 1,
1553                source_relation: None,
1554            }],
1555        );
1556
1557        let result = generate_with_backend(&query, &*backend).unwrap();
1558        let query_fn = result.query_fn.unwrap();
1559
1560        assert!(
1561            query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
1562            "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
1563        );
1564        assert!(
1565            query_fn.contains("as aggregated_free_pages"),
1566            "SQL should preserve alias keyword (as)"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_generated_rust_code_structure() {
1572        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
1573
1574        let query = make_query(
1575            "GetUser",
1576            QueryCommand::One,
1577            "SELECT id, name FROM users WHERE id = $1",
1578            vec![
1579                AnalyzedColumn {
1580                    name: "id".to_string(),
1581                    neutral_type: "int32".to_string(),
1582                    nullable: false,
1583                    ..Default::default()
1584                },
1585                AnalyzedColumn {
1586                    name: "name".to_string(),
1587                    neutral_type: "string".to_string(),
1588                    nullable: false,
1589                    ..Default::default()
1590                },
1591            ],
1592            vec![AnalyzedParam {
1593                name: "id".to_string(),
1594                neutral_type: "int32".to_string(),
1595                nullable: false,
1596                position: 1,
1597                source_relation: None,
1598            }],
1599        );
1600
1601        let result = generate_with_backend(&query, &*backend).unwrap();
1602        let query_fn = result.query_fn.unwrap();
1603
1604        assert!(query_fn.contains("pub async fn get_user("));
1605        assert!(query_fn.contains("tokio_postgres::GenericClient"));
1606        assert!(query_fn.contains("GetUserRow"));
1607
1608        assert!(query_fn.contains("SELECT id, name FROM users"));
1609    }
1610
1611    // -----------------------------------------------------------------
1612    // Nested-aggregate degradation pass (degrade_unsupported_nested_structs).
1613    // -----------------------------------------------------------------
1614
1615    fn a_nested_struct() -> NestedStructInfo {
1616        NestedStructInfo {
1617            name: "get_user_posts_row_posts".to_string(),
1618            fields: vec![NestedFieldInfo {
1619                name: "title".to_string(),
1620                neutral_type: "string".to_string(),
1621                nullable: false,
1622            }],
1623        }
1624    }
1625
1626    #[test]
1627    fn test_nested_struct_shape_unwraps_array() {
1628        let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
1629        assert!(shape.is_array);
1630        assert!(!shape.element_nullable);
1631        assert_eq!(shape.name, "GetUserPostsRowPosts");
1632    }
1633
1634    /// The LEFT JOIN form: `json_agg` emits `[null]` for a non-matching row,
1635    /// so the analyzer wraps the element in `nullable<>`.
1636    #[test]
1637    fn test_nested_struct_shape_unwraps_nullable_array_element() {
1638        let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
1639        assert!(shape.is_array);
1640        assert!(shape.element_nullable);
1641        assert_eq!(shape.name, "GetUserPostsRowPosts");
1642    }
1643
1644    #[test]
1645    fn test_nested_struct_shape_bare() {
1646        let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
1647        assert!(!shape.is_array);
1648        assert_eq!(shape.name, "GetPostAsJsonRowPost");
1649    }
1650
1651    /// The N9 regression: a user's own `@json` mapping resolves to
1652    /// `json_typed<...>`, a type scythe knows nothing about. It must not be
1653    /// mistaken for a struct scythe synthesized, or backends keyed off this
1654    /// (python-psycopg3's row construction) start calling constructors on a
1655    /// type that may not have one.
1656    #[test]
1657    fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
1658        assert!(nested_struct_shape("json_typed<EventData>").is_none());
1659    }
1660
1661    #[test]
1662    fn test_nested_struct_shape_none_for_ordinary_types() {
1663        assert!(nested_struct_shape("int32").is_none());
1664        assert!(nested_struct_shape("json").is_none());
1665        assert!(nested_struct_shape("array<int32>").is_none());
1666    }
1667
1668    #[test]
1669    fn test_degrade_unsupported_nested_structs_defaults_to_plain_json() {
1670        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1671        let backend = StubBackend { manifest };
1672        let nested = a_nested_struct();
1673        let columns = vec![
1674            AnalyzedColumn {
1675                name: "id".to_string(),
1676                neutral_type: "int32".to_string(),
1677                nullable: false,
1678                ..Default::default()
1679            },
1680            AnalyzedColumn {
1681                name: "posts".to_string(),
1682                neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1683                nullable: true,
1684                ..Default::default()
1685            },
1686            AnalyzedColumn {
1687                name: "profile".to_string(),
1688                neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1689                nullable: true,
1690                ..Default::default()
1691            },
1692        ];
1693
1694        let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1695
1696        assert_eq!(
1697            degraded[0].neutral_type, "int32",
1698            "an unrelated column must be untouched"
1699        );
1700        assert_eq!(
1701            degraded[1].neutral_type, "json",
1702            "a backend without an explicit JSON-array mapping must keep the safe plain-json fallback"
1703        );
1704        assert_eq!(
1705            degraded[2].neutral_type, "json",
1706            "an unsupported row_to_json result must remain a single object"
1707        );
1708        assert!(
1709            defs.is_empty(),
1710            "an unsupported backend must not emit a struct definition"
1711        );
1712
1713        // ~keep GH #147: the rewrite above must not be silent. Every column it
1714        // touched must produce a diagnostic naming the column, the struct
1715        // that could not be generated, the fallback it got instead, and the
1716        // backend responsible -- an unrelated column ("id") must not.
1717        assert_eq!(degradations.len(), 2, "only the two nested columns were rewritten");
1718        assert_eq!(degradations[0].column, "posts");
1719        assert_eq!(degradations[0].struct_name, "GetUserPostsRowPosts");
1720        assert_eq!(degradations[0].fallback_type, "json");
1721        assert_eq!(
1722            degradations[0].backend, "stub-backend",
1723            "the backend the user configured, from CodegenBackend::name -- not the manifest it borrows"
1724        );
1725        assert_eq!(degradations[1].column, "profile");
1726        assert_eq!(degradations[1].struct_name, "GetUserPostsRowPosts");
1727        assert_eq!(degradations[1].fallback_type, "json");
1728        assert_eq!(degradations[1].backend, "stub-backend");
1729    }
1730
1731    #[test]
1732    fn test_degrade_unsupported_nested_structs_uses_explicit_json_array_marker() {
1733        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
1734        let nested = a_nested_struct();
1735        let columns = vec![
1736            AnalyzedColumn {
1737                name: "posts".to_string(),
1738                neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1739                nullable: true,
1740                ..Default::default()
1741            },
1742            AnalyzedColumn {
1743                name: "profile".to_string(),
1744                neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
1745                nullable: true,
1746                ..Default::default()
1747            },
1748        ];
1749
1750        let (degraded, defs, degradations) =
1751            degrade_unsupported_nested_structs(&columns, &[nested], &*backend).unwrap();
1752
1753        assert_eq!(degraded[0].neutral_type, "json_array");
1754        assert_eq!(degraded[1].neutral_type, "json");
1755        assert!(defs.is_empty());
1756
1757        // ~keep The `json_array` marker path must be reported with the fallback it
1758        // actually used, not a hardcoded "json" -- a caller reporting this
1759        // finding needs to say what the column *became*.
1760        assert_eq!(degradations.len(), 2);
1761        assert_eq!(degradations[0].fallback_type, "json_array");
1762        assert_eq!(degradations[0].backend, "python-asyncpg");
1763        assert_eq!(degradations[1].fallback_type, "json");
1764    }
1765
1766    /// Minimal backend that opts into nested-struct support, to prove the
1767    /// degradation pass leaves a supported column and definition alone.
1768    struct NestedSupportingBackend {
1769        manifest: BackendManifest,
1770    }
1771
1772    impl CodegenBackend for NestedSupportingBackend {
1773        fn name(&self) -> &str {
1774            "nested-supporting-backend"
1775        }
1776        fn manifest(&self) -> &BackendManifest {
1777            &self.manifest
1778        }
1779        fn manifest_mut(&mut self) -> &mut BackendManifest {
1780            &mut self.manifest
1781        }
1782        fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1783            Ok(String::new())
1784        }
1785        fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
1786            Ok(String::new())
1787        }
1788        fn generate_query_fn(
1789            &self,
1790            _analyzed: &AnalyzedQuery,
1791            _struct_name: &str,
1792            _columns: &[ResolvedColumn],
1793            _params: &[ResolvedParam],
1794        ) -> Result<String, ScytheError> {
1795            Ok(String::new())
1796        }
1797        fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
1798            Ok(String::new())
1799        }
1800        fn generate_composite_def(
1801            &self,
1802            _composite: &scythe_core::analyzer::CompositeInfo,
1803        ) -> Result<String, ScytheError> {
1804            Ok(String::new())
1805        }
1806        fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
1807            Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
1808        }
1809    }
1810
1811    #[test]
1812    fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
1813        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
1814        let backend = NestedSupportingBackend { manifest };
1815        let nested = a_nested_struct();
1816        let columns = vec![AnalyzedColumn {
1817            name: "posts".to_string(),
1818            neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
1819            nullable: true,
1820            ..Default::default()
1821        }];
1822
1823        let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
1824
1825        assert_eq!(
1826            degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
1827            "a supported backend must leave the nested reference untouched"
1828        );
1829        assert_eq!(defs.len(), 1);
1830        assert_eq!(defs[0].name, "get_user_posts_row_posts");
1831        assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
1832        assert!(
1833            degradations.is_empty(),
1834            "a supported struct must not be reported as degraded"
1835        );
1836    }
1837
1838    /// Backends whose drivers expose a JSON document as a structural value
1839    /// may explicitly map the distinct `json_array` marker. This marker is
1840    /// deliberately not `array<json>`: the latter means a PostgreSQL
1841    /// `json[]` value and can select a typed SQL-array reader.
1842    #[test]
1843    fn test_structural_backends_match_json_array_baseline() {
1844        for backend_name in ["python-asyncpg", "elixir-postgrex", "ruby-pg"] {
1845            let backend = get_backend(backend_name, "postgresql").unwrap();
1846
1847            let baseline = make_query(
1848                "GetUserPosts",
1849                QueryCommand::Many,
1850                "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1851                vec![AnalyzedColumn {
1852                    name: "posts".to_string(),
1853                    neutral_type: "json_array".to_string(),
1854                    nullable: true,
1855                    ..Default::default()
1856                }],
1857                vec![],
1858            );
1859
1860            let mut nested_query = baseline.clone();
1861            nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1862            nested_query.nested_structs = vec![a_nested_struct()];
1863
1864            let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1865            let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1866
1867            assert_eq!(
1868                baseline_result.row_struct, nested_result.row_struct,
1869                "{backend_name} row struct output must match the JSON array baseline"
1870            );
1871            assert_eq!(
1872                baseline_result.query_fn, nested_result.query_fn,
1873                "{backend_name} query output must match the JSON array baseline"
1874            );
1875            assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1876        }
1877    }
1878
1879    #[test]
1880    fn test_php_backends_keep_native_nested_json_rows() {
1881        for backend_name in ["php-pdo", "php-amphp"] {
1882            let backend = get_backend(backend_name, "postgresql").unwrap();
1883            let baseline = make_query(
1884                "GetUserPosts",
1885                QueryCommand::Many,
1886                "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1887                vec![AnalyzedColumn {
1888                    name: "posts".to_string(),
1889                    neutral_type: "json_array".to_string(),
1890                    nullable: true,
1891                    ..Default::default()
1892                }],
1893                vec![],
1894            );
1895            let mut nested_query = baseline.clone();
1896            nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1897            let mut nested = a_nested_struct();
1898            nested.fields.push(NestedFieldInfo {
1899                name: "high_precision_total".to_string(),
1900                neutral_type: "decimal".to_string(),
1901                nullable: false,
1902            });
1903            nested_query.nested_structs = vec![nested];
1904
1905            let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1906            let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1907
1908            assert_ne!(
1909                baseline_result.row_struct, nested_result.row_struct,
1910                "{backend_name} must retain its typed nested JSON row instead of degrading"
1911            );
1912            assert_eq!(nested_result.nested_struct_defs.len(), 1);
1913            let nested_def = &nested_result.nested_struct_defs[0].code;
1914            assert!(
1915                nested_def.contains("public float $high_precision_total,")
1916                    && nested_def.contains("high_precision_total: (float) $value['high_precision_total']")
1917                    && !nested_def.contains("@var string"),
1918                "{backend_name} must expose JSON numeric values as floats instead of pretending the decoded value is an exact string:\n{nested_def}"
1919            );
1920            assert!(
1921                nested_result.degraded_nested_structs.is_empty(),
1922                "{backend_name} must not report native nested JSON support as degraded"
1923            );
1924        }
1925    }
1926
1927    /// A typed SQL-array backend must retain the old plain-JSON path. If a
1928    /// nested aggregate were degraded to `array<json>`, C# would declare a
1929    /// `List<string>` but read it through the untyped `GetValue` accessor.
1930    #[test]
1931    fn test_csharp_nested_aggregate_matches_plain_json_baseline() {
1932        let backend = get_backend("csharp-npgsql", "postgresql").unwrap();
1933        let baseline = make_query(
1934            "GetUserPosts",
1935            QueryCommand::Many,
1936            "SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
1937            vec![AnalyzedColumn {
1938                name: "posts".to_string(),
1939                neutral_type: "json".to_string(),
1940                nullable: true,
1941                ..Default::default()
1942            }],
1943            vec![],
1944        );
1945
1946        let mut nested_query = baseline.clone();
1947        nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
1948        nested_query.nested_structs = vec![a_nested_struct()];
1949
1950        let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
1951        let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
1952
1953        assert_eq!(baseline_result.row_struct, nested_result.row_struct);
1954        assert_eq!(baseline_result.query_fn, nested_result.query_fn);
1955        assert_eq!(baseline_result.model_struct, nested_result.model_struct);
1956        assert_eq!(baseline_result.enum_def, nested_result.enum_def);
1957        assert_eq!(baseline_result.nested_struct_defs, nested_result.nested_struct_defs);
1958        assert!(
1959            nested_result
1960                .row_struct
1961                .as_deref()
1962                .is_some_and(|row| row.contains("string? Posts"))
1963        );
1964        assert!(
1965            nested_result
1966                .query_fn
1967                .as_deref()
1968                .is_some_and(|query_fn| query_fn.contains("reader.GetString(0)"))
1969        );
1970    }
1971
1972    // -----------------------------------------------------------------
1973    // #187: an enum or composite inside `array<...>` must be both
1974    // referenced *and* defined, not just referenced.
1975    // -----------------------------------------------------------------
1976
1977    #[test]
1978    fn test_enum_inside_array_is_emitted_not_just_referenced() {
1979        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
1980        let mut query = make_query(
1981            "GetMany",
1982            QueryCommand::Many,
1983            "SELECT id, many_status FROM t",
1984            vec![
1985                AnalyzedColumn {
1986                    name: "id".to_string(),
1987                    neutral_type: "int32".to_string(),
1988                    nullable: false,
1989                    ..Default::default()
1990                },
1991                AnalyzedColumn {
1992                    name: "many_status".to_string(),
1993                    neutral_type: "array<enum::user_status>".to_string(),
1994                    nullable: false,
1995                    ..Default::default()
1996                },
1997            ],
1998            vec![],
1999        );
2000        query.enums = vec![EnumInfo {
2001            sql_name: "user_status".to_string(),
2002            values: vec!["active".to_string(), "banned".to_string()],
2003        }];
2004
2005        let result = generate_with_backend(&query, &*backend).unwrap();
2006
2007        let enum_def = result
2008            .enum_def
2009            .expect("an enum reached only through array<enum::...> must still get a definition emitted");
2010        assert!(
2011            enum_def.contains("pub enum UserStatus"),
2012            "expected a UserStatus enum definition; got:\n{enum_def}"
2013        );
2014
2015        let row_struct = result.row_struct.unwrap();
2016        assert!(
2017            row_struct.contains("Vec<UserStatus>"),
2018            "row struct must reference the array of the enum; got:\n{row_struct}"
2019        );
2020    }
2021
2022    #[test]
2023    fn test_composite_inside_array_is_emitted_not_just_referenced() {
2024        use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2025
2026        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2027        let mut query = make_query(
2028            "GetManyAddrs",
2029            QueryCommand::Many,
2030            "SELECT id, addrs FROM t",
2031            vec![
2032                AnalyzedColumn {
2033                    name: "id".to_string(),
2034                    neutral_type: "int32".to_string(),
2035                    nullable: false,
2036                    ..Default::default()
2037                },
2038                AnalyzedColumn {
2039                    name: "addrs".to_string(),
2040                    neutral_type: "array<composite::address>".to_string(),
2041                    nullable: false,
2042                    ..Default::default()
2043                },
2044            ],
2045            vec![],
2046        );
2047        query.composites = vec![CompositeInfo {
2048            sql_name: "address".to_string(),
2049            fields: vec![CompositeFieldInfo {
2050                name: "street".to_string(),
2051                neutral_type: "string".to_string(),
2052                nullable: false,
2053            }],
2054        }];
2055
2056        let result = generate_with_backend(&query, &*backend).unwrap();
2057
2058        let model = result
2059            .model_struct
2060            .expect("a composite reached only through array<composite::...> must still get a definition emitted");
2061        assert!(
2062            model.contains("pub struct Address"),
2063            "expected an Address composite definition; got:\n{model}"
2064        );
2065    }
2066
2067    // -----------------------------------------------------------------
2068    // Unfiled: a composite reachable only as another composite's field --
2069    // found alongside the JVM composite `fromText` fix in ddb7bb00.
2070    // -----------------------------------------------------------------
2071
2072    /// Must fail before the fix: `outer_shape` is selected directly (so its own definition was
2073    /// already emitted before this fix), but `inner_point` -- the type of `outer_shape`'s own
2074    /// `origin` field -- is referenced by no column or param directly. Before the reachability
2075    /// walk, `reachable_composite_names` didn't exist and the loop only checked
2076    /// `type_referenced_by_columns`/`nested_refs`, so `inner_point`'s definition was silently
2077    /// skipped even though `analyzed.composites` already carried it.
2078    #[test]
2079    fn test_composite_reachable_only_through_another_composites_field_is_emitted() {
2080        use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2081
2082        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2083        let mut query = make_query(
2084            "GetShape",
2085            QueryCommand::One,
2086            "SELECT id, bounds FROM shapes",
2087            vec![
2088                AnalyzedColumn {
2089                    name: "id".to_string(),
2090                    neutral_type: "int32".to_string(),
2091                    nullable: false,
2092                    ..Default::default()
2093                },
2094                AnalyzedColumn {
2095                    name: "bounds".to_string(),
2096                    neutral_type: "composite::outer_shape".to_string(),
2097                    nullable: false,
2098                    ..Default::default()
2099                },
2100            ],
2101            vec![],
2102        );
2103        query.composites = vec![
2104            CompositeInfo {
2105                sql_name: "outer_shape".to_string(),
2106                fields: vec![CompositeFieldInfo {
2107                    name: "origin".to_string(),
2108                    neutral_type: "composite::inner_point".to_string(),
2109                    nullable: false,
2110                }],
2111            },
2112            CompositeInfo {
2113                sql_name: "inner_point".to_string(),
2114                fields: vec![CompositeFieldInfo {
2115                    name: "x".to_string(),
2116                    neutral_type: "int32".to_string(),
2117                    nullable: false,
2118                }],
2119            },
2120        ];
2121
2122        let result = generate_with_backend(&query, &*backend).unwrap();
2123        let model = result
2124            .model_struct
2125            .expect("outer_shape, selected directly, must get a definition emitted");
2126
2127        assert!(
2128            model.contains("pub struct OuterShape"),
2129            "expected an OuterShape composite definition; got:\n{model}"
2130        );
2131        assert!(
2132            model.contains("pub struct InnerPoint"),
2133            "inner_point, reachable only through outer_shape's own field list, must also get a \
2134             definition emitted; got:\n{model}"
2135        );
2136    }
2137
2138    #[test]
2139    fn file_wide_nested_composite_promotion_uses_nested_capable_definition() {
2140        use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2141
2142        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2143        let mut query = make_query(
2144            "RoundTripAddress",
2145            QueryCommand::One,
2146            "SELECT $1::user_address AS address",
2147            vec![AnalyzedColumn {
2148                name: "address".to_string(),
2149                neutral_type: "composite::user_address".to_string(),
2150                nullable: false,
2151                ..Default::default()
2152            }],
2153            Vec::new(),
2154        );
2155        query.composites = vec![CompositeInfo {
2156            sql_name: "user_address".to_string(),
2157            fields: vec![CompositeFieldInfo {
2158                name: "street".to_string(),
2159                neutral_type: "string".to_string(),
2160                nullable: false,
2161            }],
2162        }];
2163        let nested_names = AHashSet::from_iter(["user_address".to_string()]);
2164
2165        let result =
2166            generate_with_backend_and_overrides_and_nested_composites(&query, backend.as_ref(), &[], &nested_names)
2167                .unwrap();
2168        let definition = result.model_struct.unwrap();
2169
2170        assert!(definition.contains("serde::Serialize"), "{definition}");
2171        assert!(definition.contains("serde::Deserialize"), "{definition}");
2172        assert_eq!(definition.matches("pub struct UserAddress").count(), 1);
2173    }
2174
2175    /// Must fail before the fix: `analyzed.composites` arrives in the analyzer's
2176    /// breadth-first discovery order, which puts a composite *before* the nested
2177    /// composite its own field names -- so the emission loop, which walked that
2178    /// vector directly, wrote `OuterShape` ahead of `InnerPoint`. Rust, TypeScript
2179    /// and the JVM backends hoist declarations and never noticed; python's
2180    /// `@dataclass` annotations are evaluated when the class body runs, so the
2181    /// generated module raised `NameError` on import. Found by
2182    /// `python_composite_enum_read_regression.rs`, whose `poly` pass runs ruff over
2183    /// the emitted file and reported `F821 Undefined name`.
2184    #[test]
2185    fn composites_are_emitted_after_the_composites_their_fields_reference() {
2186        use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2187
2188        let composites = vec![
2189            CompositeInfo {
2190                sql_name: "outer_shape".to_string(),
2191                fields: vec![CompositeFieldInfo {
2192                    name: "origin".to_string(),
2193                    neutral_type: "array<composite::inner_point>".to_string(),
2194                    nullable: false,
2195                }],
2196            },
2197            CompositeInfo {
2198                sql_name: "inner_point".to_string(),
2199                fields: vec![CompositeFieldInfo {
2200                    name: "unit".to_string(),
2201                    neutral_type: "composite::scale".to_string(),
2202                    nullable: false,
2203                }],
2204            },
2205            CompositeInfo {
2206                sql_name: "scale".to_string(),
2207                fields: vec![CompositeFieldInfo {
2208                    name: "factor".to_string(),
2209                    neutral_type: "int32".to_string(),
2210                    nullable: false,
2211                }],
2212            },
2213        ];
2214
2215        let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2216            .iter()
2217            .map(|c| c.sql_name.as_str())
2218            .collect();
2219
2220        // Two levels deep, and the outer edge is wrapped in `array<...>` -- the
2221        // dependency has to be found through `unwrap_containers`, not by an exact
2222        // `composite::` prefix match on the raw field type.
2223        assert_eq!(ordered, vec!["scale", "inner_point", "outer_shape"]);
2224    }
2225
2226    /// A cycle cannot arise from a catalog PostgreSQL accepted, but the walk must
2227    /// still terminate and still emit every composite exactly once rather than
2228    /// recursing forever on a catalog assembled some other way.
2229    #[test]
2230    fn composites_in_dependency_order_terminates_on_a_cycle() {
2231        use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
2232
2233        let composites = vec![
2234            CompositeInfo {
2235                sql_name: "a".to_string(),
2236                fields: vec![CompositeFieldInfo {
2237                    name: "b".to_string(),
2238                    neutral_type: "composite::b".to_string(),
2239                    nullable: false,
2240                }],
2241            },
2242            CompositeInfo {
2243                sql_name: "b".to_string(),
2244                fields: vec![CompositeFieldInfo {
2245                    name: "a".to_string(),
2246                    neutral_type: "composite::a".to_string(),
2247                    nullable: false,
2248                }],
2249            },
2250        ];
2251
2252        let ordered: Vec<&str> = composites_in_dependency_order(&composites)
2253            .iter()
2254            .map(|c| c.sql_name.as_str())
2255            .collect();
2256
2257        assert_eq!(ordered.len(), 2, "every composite must still be emitted exactly once");
2258        assert!(ordered.contains(&"a") && ordered.contains(&"b"));
2259    }
2260
2261    #[test]
2262    fn test_unwrap_containers() {
2263        assert_eq!(unwrap_containers("enum::user_status"), "enum::user_status");
2264        assert_eq!(unwrap_containers("array<enum::user_status>"), "enum::user_status");
2265        assert_eq!(unwrap_containers("array<composite::address>"), "composite::address");
2266        assert_eq!(
2267            unwrap_containers("array<nullable<enum::user_status>>"),
2268            "enum::user_status"
2269        );
2270        assert_eq!(unwrap_containers("nullable<array<int32>>"), "int32");
2271        assert_eq!(unwrap_containers("int32"), "int32");
2272    }
2273
2274    // -----------------------------------------------------------------
2275    // #188: a `:grouped` parent column named `children` must not silently
2276    // collide with the synthesized `children` field.
2277    // -----------------------------------------------------------------
2278
2279    #[test]
2280    fn test_grouped_parent_column_named_children_collides_with_synthesized_field() {
2281        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
2282        let parent_cols = vec![
2283            AnalyzedColumn {
2284                name: "id".to_string(),
2285                neutral_type: "int32".to_string(),
2286                nullable: false,
2287                ..Default::default()
2288            },
2289            AnalyzedColumn {
2290                name: "children".to_string(),
2291                neutral_type: "string".to_string(),
2292                nullable: false,
2293                ..Default::default()
2294            },
2295        ];
2296        let child_cols = vec![AnalyzedColumn {
2297            name: "order_id".to_string(),
2298            neutral_type: "int32".to_string(),
2299            nullable: false,
2300            ..Default::default()
2301        }];
2302        let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
2303
2304        let query = AnalyzedQuery::build(|aq| {
2305            aq.name = "GetUsersWithOrders".to_string();
2306            aq.command = QueryCommand::Grouped;
2307            aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
2308                  SELECT u.id, u.children, o.id AS order_id\n\
2309                  FROM users u\n\
2310                  JOIN orders o ON o.user_id = u.id"
2311                .to_string();
2312            aq.columns = all_cols;
2313            aq.params = vec![];
2314            aq.deprecated = None;
2315            aq.source_table = None;
2316            aq.composites = vec![];
2317            aq.enums = vec![];
2318            aq.optional_params = vec![];
2319            aq.group_by = Some(GroupByConfig {
2320                table: "users".to_string(),
2321                key_column: "id".to_string(),
2322                parent_columns: parent_cols,
2323                child_columns: child_cols,
2324            });
2325            aq.custom = vec![];
2326        });
2327
2328        let result = generate_with_backend(&query, &*backend);
2329        let err = result.expect_err(
2330            "a parent column literally named 'children' must be rejected, not silently \
2331             produce two `children` fields on the generated struct",
2332        );
2333        assert_eq!(err.code, ErrorCode::DuplicateAlias);
2334        assert!(
2335            err.message.contains("children"),
2336            "error should name the colliding field, got: {}",
2337            err.message
2338        );
2339    }
2340
2341    // -----------------------------------------------------------------
2342    // #164: `SELECT *` must declare and reference the same struct name,
2343    // across every registered backend -- not just the two (rust-sqlx,
2344    // rust-tokio-postgres) that used to get it right by hand.
2345    // -----------------------------------------------------------------
2346
2347    /// Strip line comments (`//`, `#`), block comments (`/* ... */`), and
2348    /// triple-quoted docstrings (`"""..."""`, `'''...'''`) from generated
2349    /// source.
2350    ///
2351    /// Every backend's doc comment for a `SELECT *` query says something
2352    /// like "Row type for User query" / "Fetch all User rows" -- the *SQL*
2353    /// entity name in prose, which is a whole-word match for `struct_name`
2354    /// that has nothing to do with whether the code actually declares or
2355    /// references that identifier. Without stripping these first,
2356    /// [`contains_identifier`] finds "User" in that prose and reports a
2357    /// backend as correct when its actual declaration says `UserRow`.
2358    fn strip_comments(source: &str) -> String {
2359        let mut out = String::with_capacity(source.len());
2360        let mut i = 0;
2361        while i < source.len() {
2362            let rest = &source[i..];
2363            if rest.starts_with("//") || rest.starts_with('#') {
2364                match rest.find('\n') {
2365                    Some(nl) => i += nl,
2366                    None => i = source.len(),
2367                }
2368                continue;
2369            }
2370            if rest.starts_with("/*") {
2371                match rest.find("*/") {
2372                    Some(end) => i += end + 2,
2373                    None => i = source.len(),
2374                }
2375                continue;
2376            }
2377            if let Some(quote) = ["\"\"\"", "'''"].into_iter().find(|q| rest.starts_with(q)) {
2378                match rest[quote.len()..].find(quote) {
2379                    Some(end) => i += quote.len() + end + quote.len(),
2380                    None => i = source.len(),
2381                }
2382                continue;
2383            }
2384            let ch = rest.chars().next().expect("i < source.len()");
2385            out.push(ch);
2386            i += ch.len_utf8();
2387        }
2388        out
2389    }
2390
2391    /// Whether `needle` appears in `haystack` as a whole identifier --
2392    /// bounded by a non-identifier character (or a string edge) on both
2393    /// sides.
2394    ///
2395    /// The 50+ backends here don't share one declaration/reference syntax
2396    /// (`pub struct X {`, `export interface X {`, `type X struct {`,
2397    /// `class X:`, `record X(...)`, `object XTable : IntIdTable(...)`, ...),
2398    /// so this test cannot parse each one's grammar and still be a single
2399    /// check "worth more than a per-backend one". What every one of them
2400    /// does share is spelling the struct/class/record name as one
2401    /// identifier token -- so a boundary-aware substring search is enough
2402    /// to tell "declares/references `User`" apart from "declares/references
2403    /// `UserRow`" without knowing any backend's actual syntax.
2404    fn contains_identifier(haystack: &str, needle: &str) -> bool {
2405        if needle.is_empty() {
2406            return false;
2407        }
2408        let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
2409        let mut start = 0;
2410        while let Some(rel) = haystack[start..].find(needle) {
2411            let idx = start + rel;
2412            let before_ok = haystack[..idx].chars().next_back().is_none_or(|c| !is_ident_char(c));
2413            let after_ok = haystack[idx + needle.len()..]
2414                .chars()
2415                .next()
2416                .is_none_or(|c| !is_ident_char(c));
2417            if before_ok && after_ok {
2418                return true;
2419            }
2420            start = idx + 1;
2421            if start >= haystack.len() {
2422                break;
2423            }
2424        }
2425        false
2426    }
2427
2428    /// Every backend `get_backend` recognizes, keyed by its canonical
2429    /// (first-listed) name in `backends::get_backend`'s match arms.
2430    const ALL_BACKEND_NAMES: &[&str] = &[
2431        "rust-sqlx",
2432        "rust-tokio-postgres",
2433        "python-psycopg3",
2434        "python-asyncpg",
2435        "python-aiomysql",
2436        "python-aiosqlite",
2437        "python-duckdb",
2438        "typescript-postgres",
2439        "javascript-postgres",
2440        "typescript-pg",
2441        "javascript-pg",
2442        "typescript-mysql2",
2443        "javascript-mysql2",
2444        "typescript-better-sqlite3",
2445        "javascript-better-sqlite3",
2446        "typescript-duckdb",
2447        "javascript-duckdb",
2448        "typescript-node-sqlite",
2449        "javascript-node-sqlite",
2450        "typescript-wasm-sqlite",
2451        "javascript-wasm-sqlite",
2452        "typescript-kysely",
2453        "go-database-sql",
2454        "go-pgx",
2455        "java-jdbc",
2456        "java-r2dbc",
2457        "kotlin-exposed",
2458        "kotlin-jdbc",
2459        "kotlin-r2dbc",
2460        "csharp-npgsql",
2461        "csharp-mysqlconnector",
2462        "csharp-microsoft-sqlite",
2463        "elixir-postgrex",
2464        "elixir-ecto",
2465        "elixir-myxql",
2466        "elixir-exqlite",
2467        "ruby-pg",
2468        "ruby-mysql2",
2469        "ruby-sqlite3",
2470        "ruby-trilogy",
2471        "php-pdo",
2472        "php-amphp",
2473        "rust-tiberius",
2474        "python-pyodbc",
2475        "typescript-mssql",
2476        "javascript-mssql",
2477        "csharp-sqlclient",
2478        "ruby-tiny-tds",
2479        "elixir-tds",
2480        "rust-sibyl",
2481        "python-oracledb",
2482        "typescript-oracledb",
2483        "javascript-oracledb",
2484        "go-godror",
2485        "csharp-oracle",
2486        "ruby-oci8",
2487        "elixir-jamdb",
2488        "python-snowflake",
2489        "typescript-snowflake",
2490        "javascript-snowflake",
2491        "go-gosnowflake",
2492        "csharp-snowflake",
2493    ];
2494
2495    /// `get_backend(name, engine)` rejects an engine the backend's manifest
2496    /// doesn't cover (e.g. `go-database-sql` has no PostgreSQL manifest at
2497    /// all); try engines in order and use whichever one the backend
2498    /// actually accepts, since this test cares about naming consistency,
2499    /// not which engine.
2500    fn backend_for_select_star(name: &str) -> Box<dyn CodegenBackend> {
2501        const ENGINES: &[&str] = &[
2502            "postgresql",
2503            "mysql",
2504            "sqlite",
2505            "mssql",
2506            "mariadb",
2507            "duckdb",
2508            "oracle",
2509            "snowflake",
2510            "redshift",
2511        ];
2512        for engine in ENGINES {
2513            if let Ok(backend) = get_backend(name, engine) {
2514                return backend;
2515            }
2516        }
2517        panic!("no known engine works for backend '{name}'");
2518    }
2519
2520    fn select_star_query() -> AnalyzedQuery {
2521        let mut query = make_query(
2522            "GetAllUsers",
2523            QueryCommand::Many,
2524            "SELECT * FROM users",
2525            vec![
2526                AnalyzedColumn {
2527                    name: "id".to_string(),
2528                    neutral_type: "int32".to_string(),
2529                    nullable: false,
2530                    ..Default::default()
2531                },
2532                AnalyzedColumn {
2533                    name: "name".to_string(),
2534                    neutral_type: "string".to_string(),
2535                    nullable: false,
2536                    ..Default::default()
2537                },
2538            ],
2539            vec![],
2540        );
2541        query.source_table = Some("users".to_string());
2542        query
2543    }
2544
2545    /// One entry in [`KNOWN_DIVERGENT_BACKENDS`] or [`NOT_APPLICABLE_BACKENDS`]:
2546    /// a backend name paired with why it is listed, so the list doubles as
2547    /// its own documentation.
2548    struct BackendNote {
2549        backend: &'static str,
2550        reason: &'static str,
2551    }
2552
2553    /// Ratcheting allowlist for
2554    /// [`test_select_star_declares_and_references_the_same_struct_name_across_all_backends`],
2555    /// matching the pattern established by `scripts/torture-expected-failures.txt`
2556    /// / `scripts/check-generated-backends.py`: the test fails in *both*
2557    /// directions --
2558    /// - a backend not listed here that diverges is a regression: investigate,
2559    ///   don't just add a line.
2560    /// - a backend listed here that now agrees means this entry is stale: the
2561    ///   underlying defect was fixed, delete the line.
2562    ///
2563    /// No percentage, no tolerance, no `#[ignore]`: every entry names one
2564    /// backend and one reason, and the list is forced to shrink to empty as
2565    /// backends get fixed instead of silently rotting.
2566    ///
2567    /// It started at 50 entries. The 49 that shared the `row_suffix`
2568    /// delegation bug are gone: `generate_model_struct` and
2569    /// `generate_row_struct` are now both provided methods that name the
2570    /// type once — through [`model_struct_name`] and [`row_struct_name`]
2571    /// respectively — and hand that final name to the backend's
2572    /// `generate_struct_decl`, which is contractually forbidden from
2573    /// deriving another one (#164). There is no per-backend name derivation
2574    /// left for a 50th backend to get wrong.
2575    ///
2576    /// It is now empty. The last entry was `kotlin-exposed`, whose
2577    /// `generate_model_struct` emitted an Exposed `object XTable :
2578    /// IntIdTable(...)` in place of a row type entirely, so the shared naming
2579    /// fix could not reach it. It now emits both — the table object, because
2580    /// that genuinely is the Exposed idiom, and the row `data class`, because
2581    /// `determine_struct_name` constructs that type regardless and leaving it
2582    /// undeclared is what #214 was about.
2583    const KNOWN_DIVERGENT_BACKENDS: &[BackendNote] = &[];
2584
2585    /// Backends this check cannot render a verdict on at all -- distinct
2586    /// from [`KNOWN_DIVERGENT_BACKENDS`], which lists backends the check
2587    /// *did* run and found broken. The `javascript-*` backends type
2588    /// their generated functions entirely through JSDoc comments (see
2589    /// `generate_js_typedef_row_struct` / the `js_mode` branches in
2590    /// `backends/typescript_*.rs`): both the struct name they declare and
2591    /// the one they reference live only inside comment text, which
2592    /// [`strip_comments`] deliberately removes before matching (see its own
2593    /// doc comment for why -- otherwise unrelated prose like "Fetch all User
2594    /// rows" reads as a match). After stripping there is no runtime
2595    /// identifier left to check either way, so this check always reports
2596    /// both sides as absent regardless of whether the row_suffix bug is
2597    /// fixed. Excluding these from the pass/fail logic entirely, but
2598    /// listing them here by name and reason, is what keeps that exclusion
2599    /// visible instead of reading as a quiet, unexplained pass -- the same
2600    /// defect shape (a skip that looks like a pass) this whole effort exists
2601    /// to catch.
2602    const NOT_APPLICABLE_BACKENDS: &[BackendNote] = &[
2603        BackendNote {
2604            backend: "javascript-postgres",
2605            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2606                      the check to find once comments are stripped",
2607        },
2608        BackendNote {
2609            backend: "javascript-pg",
2610            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2611                      the check to find once comments are stripped",
2612        },
2613        BackendNote {
2614            backend: "javascript-mysql2",
2615            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2616                      the check to find once comments are stripped",
2617        },
2618        BackendNote {
2619            backend: "javascript-better-sqlite3",
2620            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2621                      the check to find once comments are stripped",
2622        },
2623        BackendNote {
2624            backend: "javascript-node-sqlite",
2625            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2626                      the check to find once comments are stripped",
2627        },
2628        BackendNote {
2629            backend: "javascript-wasm-sqlite",
2630            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2631                      the check to find once comments are stripped",
2632        },
2633        BackendNote {
2634            backend: "javascript-snowflake",
2635            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2636                      the check to find once comments are stripped",
2637        },
2638        BackendNote {
2639            backend: "javascript-duckdb",
2640            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2641                      the check to find once comments are stripped",
2642        },
2643        BackendNote {
2644            backend: "javascript-oracledb",
2645            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2646                      the check to find once comments are stripped",
2647        },
2648        BackendNote {
2649            backend: "javascript-mssql",
2650            reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
2651                      the check to find once comments are stripped",
2652        },
2653    ];
2654
2655    /// Ratcheting guard for #164: `SELECT *` must declare and reference the
2656    /// same struct name. Every backend not in [`KNOWN_DIVERGENT_BACKENDS`]
2657    /// or [`NOT_APPLICABLE_BACKENDS`] must agree; every backend in
2658    /// `KNOWN_DIVERGENT_BACKENDS` must currently disagree (a stale entry
2659    /// fails the test exactly as loudly as a regression does -- see that
2660    /// const's doc comment). This makes the list impossible to forget about:
2661    /// it can only shrink, never quietly rot.
2662    #[test]
2663    fn test_select_star_declares_and_references_the_same_struct_name_across_all_backends() {
2664        let query = select_star_query();
2665
2666        let mut regressions = Vec::new();
2667        let mut stale_entries = Vec::new();
2668        let mut seen_not_applicable: Vec<&str> = Vec::new();
2669
2670        for &name in ALL_BACKEND_NAMES {
2671            if let Some(note) = NOT_APPLICABLE_BACKENDS.iter().find(|n| n.backend == name) {
2672                seen_not_applicable.push(note.backend);
2673                continue;
2674            }
2675
2676            let backend = backend_for_select_star(name);
2677            let struct_name = determine_struct_name(&query, backend.manifest());
2678
2679            let agrees = match generate_with_backend(&query, &*backend) {
2680                Err(_) => false,
2681                Ok(result) => match (result.model_struct, result.query_fn) {
2682                    (Some(model_struct), Some(query_fn)) => {
2683                        let declared = contains_identifier(&strip_comments(&model_struct), &struct_name);
2684                        let referenced = contains_identifier(&strip_comments(&query_fn), &struct_name);
2685                        declared && referenced
2686                    }
2687                    _ => false,
2688                },
2689            };
2690
2691            let listed = KNOWN_DIVERGENT_BACKENDS.iter().find(|n| n.backend == name);
2692            match (agrees, listed) {
2693                (true, Some(_)) => stale_entries.push(name),
2694                (false, None) => regressions.push(name),
2695                // Confirmed still divergent for the reason on file -- print
2696                // it (visible with `--nocapture`) so `reason` is more than
2697                // documentation nobody's code path ever reads.
2698                (false, Some(note)) => eprintln!("expected divergence, {name}: {}", note.reason),
2699                (true, None) => {}
2700            }
2701        }
2702
2703        // NOT_APPLICABLE_BACKENDS naming a backend ALL_BACKEND_NAMES doesn't
2704        // know about would silently exclude nothing (the `if let` above
2705        // never matches it) -- catch that the same way a stale
2706        // KNOWN_DIVERGENT_BACKENDS entry is caught, rather than let the
2707        // exclusion quietly fail to apply.
2708        let unknown_not_applicable: Vec<&str> = NOT_APPLICABLE_BACKENDS
2709            .iter()
2710            .map(|n| n.backend)
2711            .filter(|b| !seen_not_applicable.contains(b))
2712            .collect();
2713
2714        let mut failures = Vec::new();
2715        for name in &regressions {
2716            let reason = "diverges on SELECT * struct naming and is not in KNOWN_DIVERGENT_BACKENDS";
2717            failures.push(format!(
2718                "REGRESSION: {name}: {reason} -- investigate before adding a line"
2719            ));
2720        }
2721        for name in &stale_entries {
2722            failures.push(format!(
2723                "STALE ALLOWLIST: {name}: listed in KNOWN_DIVERGENT_BACKENDS but now agrees -- delete its entry"
2724            ));
2725        }
2726        for name in &unknown_not_applicable {
2727            failures.push(format!(
2728                "STALE ALLOWLIST: {name}: listed in NOT_APPLICABLE_BACKENDS but is not in ALL_BACKEND_NAMES"
2729            ));
2730        }
2731
2732        assert!(
2733            failures.is_empty(),
2734            "{} of {} backends need attention:\n{}",
2735            failures.len(),
2736            ALL_BACKEND_NAMES.len(),
2737            failures.join("\n")
2738        );
2739    }
2740}