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