scythe_codegen/backend_trait.rs
1use scythe_core::analyzer::{AnalyzedQuery, CompositeInfo, EnumInfo, NestedStructInfo};
2use scythe_core::errors::{ErrorCode, ScytheError};
3
4use crate::GeneratedCode;
5
6/// Information needed to generate an RBS type signature file.
7#[derive(Debug, Clone)]
8pub struct RbsGenerationContext {
9 pub queries: Vec<RbsQueryInfo>,
10 pub enums: Vec<RbsEnumInfo>,
11}
12
13/// Per-query info for RBS generation.
14#[derive(Debug, Clone)]
15pub struct RbsQueryInfo {
16 pub func_name: String,
17 pub struct_name: Option<String>,
18 pub columns: Vec<ResolvedColumn>,
19 pub params: Vec<ResolvedParam>,
20 pub command: scythe_core::parser::QueryCommand,
21}
22
23/// Per-enum info for RBS generation.
24#[derive(Debug, Clone)]
25pub struct RbsEnumInfo {
26 pub type_name: String,
27 pub values: Vec<String>,
28}
29
30/// A column with its type resolved to the target language.
31#[derive(Debug, Clone, Default)]
32pub struct ResolvedColumn {
33 pub name: String,
34 pub field_name: String,
35 pub lang_type: String,
36 pub full_type: String,
37 pub neutral_type: String,
38 pub nullable: bool,
39 /// Alias of the outer-joined relation this column came from, when the
40 /// column was widened to nullable by that join. See
41 /// [`scythe_core::analyzer::AnalyzedColumn::join_group`].
42 pub join_group: Option<String>,
43 /// Whether the column was nullable in the schema, before outer-join
44 /// widening.
45 pub nullable_before_join: bool,
46 /// The raw SQL type this column was derived from (see
47 /// [`scythe_core::analyzer::AnalyzedColumn::sql_type`]). Backends that need
48 /// to distinguish storage representations the neutral type collapses
49 /// (Oracle CLOB vs. VARCHAR2, both `neutral_type == "string"`) match on
50 /// this instead of `neutral_type`.
51 pub sql_type: String,
52}
53
54impl ResolvedColumn {
55 /// Whether this column can only be null because its outer join found no
56 /// row — making it a usable discriminant for a union.
57 pub fn is_join_discriminant(&self) -> bool {
58 self.join_group.is_some() && !self.nullable_before_join
59 }
60}
61
62/// A parameter with its type resolved to the target language.
63#[derive(Debug, Clone)]
64pub struct ResolvedParam {
65 pub name: String,
66 pub field_name: String,
67 pub lang_type: String,
68 pub full_type: String,
69 pub borrowed_type: String,
70 pub neutral_type: String,
71 pub nullable: bool,
72}
73
74/// Inputs for [`CodegenBackend::generate_grouped_query_fn`].
75///
76/// The grouped query-fn contract carries enough context (analyzed query, both
77/// struct names, the flat and split column sets, params, and the grouping key)
78/// that passing it positionally trips `clippy::too_many_arguments`. Bundling it
79/// into one struct keeps the per-language implementations uniform as backends
80/// opt in to grouped codegen.
81pub struct GroupedQueryFn<'a> {
82 /// Full analyzed query (SQL, name, params, optional_params, deprecated, …).
83 pub analyzed: &'a AnalyzedQuery,
84 /// Name of the generated parent struct.
85 pub parent_struct_name: &'a str,
86 /// Name of the generated child struct.
87 pub child_struct_name: &'a str,
88 /// All resolved columns in flat SELECT order; used for row decoding.
89 pub all_columns: &'a [ResolvedColumn],
90 /// Resolved columns belonging to the parent struct.
91 pub parent_columns: &'a [ResolvedColumn],
92 /// Resolved columns belonging to the child struct(s).
93 pub child_columns: &'a [ResolvedColumn],
94 /// Resolved query parameters.
95 pub params: &'a [ResolvedParam],
96 /// Grouping key column name in the flat result row
97 /// (matches [`scythe_core::analyzer::GroupByConfig::key_column`]).
98 pub key_column: &'a str,
99}
100
101/// Trait that all codegen backends must implement.
102pub trait CodegenBackend: Send + Sync {
103 /// The backend's name (e.g. "rust-sqlx", "rust-tokio-postgres").
104 fn name(&self) -> &str;
105
106 /// The backend's manifest (type mappings, naming conventions, etc).
107 fn manifest(&self) -> &scythe_backend::manifest::BackendManifest;
108
109 /// Mutable access to the backend's own manifest, so a per-target manifest
110 /// overlay (`manifest = "..."` in `[[sql.gen]]`) can be merged in right
111 /// after construction.
112 ///
113 /// Deliberately required rather than defaulted: backends read
114 /// `self.manifest` directly for naming and type resolution, so a default
115 /// that silently did nothing would make `manifest = "..."` a no-op for
116 /// whichever backends forgot to override it — the exact class of silent,
117 /// per-backend divergence that #82 was filed for. Making it required
118 /// turns that into a compile error.
119 fn manifest_mut(&mut self) -> &mut scythe_backend::manifest::BackendManifest;
120
121 /// The file extension for this backend's generated output, without a
122 /// leading dot.
123 ///
124 /// Defaults to the manifest's `[backend] file_extension`, which is right
125 /// for every backend that owns its manifest. It is overridable because the
126 /// `javascript-*` backends deliberately reuse the TypeScript manifests —
127 /// they differ only in emit mode, so giving them their own manifests would
128 /// duplicate every type mapping to change one field. `[backend]` is not
129 /// overridable through a manifest overlay (it is identity, not
130 /// configuration), so the extension has to be expressible here instead.
131 fn output_extension(&self) -> &str {
132 &self.manifest().backend.file_extension
133 }
134
135 /// Generate a row struct for a query result.
136 fn generate_row_struct(&self, query_name: &str, columns: &[ResolvedColumn]) -> Result<String, ScytheError>;
137
138 /// Generate a model struct for a table.
139 fn generate_model_struct(&self, table_name: &str, columns: &[ResolvedColumn]) -> Result<String, ScytheError>;
140
141 /// Generate a query function.
142 fn generate_query_fn(
143 &self,
144 analyzed: &AnalyzedQuery,
145 struct_name: &str,
146 columns: &[ResolvedColumn],
147 params: &[ResolvedParam],
148 ) -> Result<String, ScytheError>;
149
150 /// Generate an enum definition.
151 fn generate_enum_def(&self, enum_info: &EnumInfo) -> Result<String, ScytheError>;
152
153 /// Generate a composite type definition.
154 fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError>;
155
156 /// Generate text that must be the literal first bytes of the generated
157 /// file — before any comment, including the provenance header that
158 /// assembly prepends ahead of [`file_header`](Self::file_header).
159 ///
160 /// Some languages have syntax that loses its meaning, or becomes a
161 /// syntax error, if anything precedes it: PHP's `<?php` tag must open
162 /// the file, and Ruby's `# frozen_string_literal: true` magic comment is
163 /// only recognized on line 1 (or line 2 after a shebang) — a comment
164 /// placed above it makes Ruby silently ignore the pragma. Backends with
165 /// such a constraint override this method; everything else that may
166 /// legally follow a leading comment (imports, docstrings, `#!`-style
167 /// banners) stays in [`file_header`](Self::file_header).
168 ///
169 /// Returns an empty string by default; backends may override.
170 fn file_preamble(&self) -> String {
171 String::new()
172 }
173
174 /// Generate a struct definition for a nested-aggregate result shape
175 /// (`json_agg(o.*)`, `row_to_json(u.*)`, ...). PostgreSQL only; see
176 /// [`scythe_core::analyzer::AnalyzedQuery::nested_structs`].
177 ///
178 /// ## Opt-in, not opt-out
179 ///
180 /// Returns `Ok(None)` by default — "I do not support this" — so a
181 /// backend is only at risk from this feature if it explicitly
182 /// overrides the method. `crates/scythe-codegen/src/lib.rs` rewrites
183 /// any column referencing a struct this returns `Ok(None)` for back to
184 /// plain `json` *before* type resolution, so the default keeps a
185 /// non-opted-in backend's output byte-identical to what it produced
186 /// before nested-aggregate inference existed.
187 ///
188 /// Deliberately not the same shape as `generate_composite_def`, which
189 /// always returns a definition (`CompositeInfo` only ever exists because
190 /// a column already referenced a real catalog composite; there is
191 /// nothing to opt out of). Overriding to return `Ok(Some(_))` is safe
192 /// only when the backend's row-decoding path actually deserializes the
193 /// resulting JSON into the generated type — a backend whose
194 /// `json_nested<T>` container merely names the type without decoding it
195 /// would produce code that compiles and is wrong, which is worse than
196 /// not supporting it. `Err(_)` is reserved for a genuine failure (e.g. a
197 /// field's neutral type doesn't resolve) and must propagate rather than
198 /// degrade.
199 ///
200 /// An override must also gate on the *engine* it was constructed for,
201 /// not only on being a PostgreSQL backend: `rust-tokio-postgres`,
202 /// `go-pgx` and `python-psycopg3` all list `redshift` in
203 /// [`Self::supported_engines`], and Redshift has no `json_agg`.
204 fn generate_nested_struct_def(&self, _nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
205 Ok(None)
206 }
207
208 /// Generate an enum definition for an enum reachable from a nested
209 /// aggregate's field list.
210 ///
211 /// Same enum, different requirements: a value inside a `json_agg` result
212 /// arrives as JSON and is decoded by the language's JSON library, not by
213 /// the database driver, so a backend whose ordinary
214 /// [`Self::generate_enum_def`] emits only driver traits (`sqlx::Type`,
215 /// `postgres_types`) must add the JSON ones here or the nested struct
216 /// will not satisfy its own `Deserialize` bound. Defaults to
217 /// `generate_enum_def`, which is correct for every backend that already
218 /// decodes enums as plain strings.
219 fn generate_enum_def_for_nested(&self, enum_info: &EnumInfo) -> Result<String, ScytheError> {
220 self.generate_enum_def(enum_info)
221 }
222
223 /// Composite counterpart of [`Self::generate_enum_def_for_nested`], for a
224 /// composite type reachable from a nested aggregate's field list.
225 fn generate_composite_def_for_nested(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
226 self.generate_composite_def(composite)
227 }
228
229 /// Generate a file-level header (imports, docstring, etc).
230 /// Returns an empty string by default; backends may override.
231 fn file_header(&self) -> String {
232 String::new()
233 }
234
235 /// Generate a file-level header using already generated code as context.
236 ///
237 /// Backends with conditional imports can inspect generated fragments and avoid
238 /// broad engine-level import guesses. The default preserves existing behavior.
239 fn file_header_for_results(&self, _generated: &[GeneratedCode]) -> String {
240 self.file_header()
241 }
242
243 /// Generate a file-level footer (closing braces, etc).
244 /// Returns an empty string by default; backends may override.
245 fn file_footer(&self) -> String {
246 String::new()
247 }
248
249 /// Generate a class header that wraps query functions only.
250 /// When non-empty, the assembly will emit all type definitions (enums,
251 /// row structs, model structs) first, then this class header, then all
252 /// query functions, then the file footer.
253 /// Returns an empty string by default (no class wrapper).
254 fn query_class_header(&self) -> String {
255 String::new()
256 }
257
258 /// Generate code that should be emitted after the file footer.
259 /// This is useful for backends that need top-level code after a class wrapper.
260 /// For example, C# extension methods must be top-level, not nested.
261 /// Returns an empty string by default.
262 fn post_footer(&self) -> String {
263 String::new()
264 }
265
266 /// Generate an RBS type signature file for Ruby backends.
267 /// Returns `None` by default; Ruby backends override this.
268 fn generate_rbs_file(&self, _context: &RbsGenerationContext) -> Option<String> {
269 None
270 }
271
272 /// Generate parent and child structs for a `:grouped` query.
273 ///
274 /// A `:grouped` query folds flat rows from a normal SQL SELECT into a nested
275 /// parent/child structure entirely on the client side — the SQL itself is
276 /// unchanged from a regular `:many` query.
277 ///
278 /// ## Struct layout
279 ///
280 /// * **Child struct** (`child_struct_name`): contains all `child_columns`.
281 /// Defined first in the output to avoid forward references.
282 /// * **Parent struct** (`parent_struct_name`): contains all `parent_columns`
283 /// plus one extra field `children: Vec<child_struct_name>` (or the
284 /// language-native equivalent collection type).
285 ///
286 /// ## Grouping semantics
287 ///
288 /// The generated query function fetches flat rows and folds them into an
289 /// **order-preserving** list of parent structs, appending each row's child
290 /// fields to the matching parent's collection. Equality on `key_column` is
291 /// the fold predicate.
292 ///
293 /// ## Parameters
294 ///
295 /// * `parent_struct_name` – fully qualified struct/class name for the parent
296 /// (e.g. `"GetUsersWithOrdersRow"`).
297 /// * `child_struct_name` – fully qualified struct/class name for the child
298 /// (e.g. `"GetUsersWithOrdersChildRow"`).
299 /// * `parent_columns` – resolved columns belonging to the parent table.
300 /// * `child_columns` – resolved columns belonging to child table(s).
301 /// * `key_column` – SQL column name used as the grouping key; identifies
302 /// the boundary between parent groups (matches [`GroupByConfig::key_column`]).
303 ///
304 /// ## Return value
305 ///
306 /// A string containing both struct definitions (child first, parent second),
307 /// stored in [`GeneratedCode::row_struct`].
308 ///
309 /// ## Default implementation
310 ///
311 /// Returns an error:
312 /// *"grouped queries are not yet supported by the '\<name\>' backend"*.
313 /// Backends opt in by overriding this method.
314 fn generate_grouped_structs(
315 &self,
316 _parent_struct_name: &str,
317 _child_struct_name: &str,
318 _parent_columns: &[ResolvedColumn],
319 _child_columns: &[ResolvedColumn],
320 _key_column: &str,
321 ) -> Result<String, ScytheError> {
322 Err(ScytheError::new(
323 ErrorCode::InternalError,
324 format!("grouped queries are not yet supported by the '{}' backend", self.name()),
325 ))
326 }
327
328 /// Generate the query function for a `:grouped` query.
329 ///
330 /// The function runs the flat SQL from `analyzed.sql`, decodes each row,
331 /// and folds the rows into an **order-preserving** `Vec<parent_struct_name>`
332 /// (or the language-native equivalent), grouping by `key_column` and
333 /// appending each row's child fields to the matching parent's `children`
334 /// collection.
335 ///
336 /// ## Parameters
337 ///
338 /// All inputs are bundled in [`GroupedQueryFn`]: the analyzed query, both
339 /// generated struct names, the flat and split (parent/child) column sets,
340 /// the resolved params, and the grouping key column.
341 ///
342 /// ## Return value
343 ///
344 /// A string containing the full query function definition, stored in
345 /// [`GeneratedCode::query_fn`].
346 ///
347 /// ## Default implementation
348 ///
349 /// Returns an error:
350 /// *"grouped queries are not yet supported by the '\<name\>' backend"*.
351 /// Backends opt in by overriding this method.
352 fn generate_grouped_query_fn(&self, _request: &GroupedQueryFn<'_>) -> Result<String, ScytheError> {
353 Err(ScytheError::new(
354 ErrorCode::InternalError,
355 format!("grouped queries are not yet supported by the '{}' backend", self.name()),
356 ))
357 }
358
359 /// Apply per-backend configuration options from [[sql.gen]].
360 ///
361 /// Backends that take options override this: parse each one, and reject
362 /// anything else via [`crate::backend_options::reject_unknown_options`]
363 /// against that backend's own known-key list -- see any TypeScript
364 /// backend's `apply_options` for the established shape.
365 ///
366 /// The default rejects every key (an empty known-key list), which is the
367 /// correct behavior for the majority of backends that take no options at
368 /// all -- and, just as importantly, is what a brand-new backend gets for
369 /// free if its author forgets to override this. Before #103 the default
370 /// was `Ok(())`, so any unrecognized key -- a typo, or a real key that
371 /// TOML happily parsed but no override read -- was silently discarded
372 /// everywhere except the 11 TypeScript backends, which called
373 /// `reject_unknown_options` explicitly. The same typo behaving
374 /// differently depending on target language was itself a trap; inverting
375 /// the default closes it for every backend at once instead of requiring
376 /// each one to opt in.
377 fn apply_options(&mut self, options: &std::collections::HashMap<String, String>) -> Result<(), ScytheError> {
378 crate::backend_options::reject_unknown_options(&[], options)
379 }
380
381 /// Database engines this backend supports.
382 /// Defaults to PostgreSQL only. Multi-DB backends override this.
383 fn supported_engines(&self) -> &[&str] {
384 &["postgresql"]
385 }
386}