Skip to main content

scythe_codegen/
lib.rs

1pub mod backend_trait;
2pub mod backends;
3pub mod overrides;
4pub mod resolve;
5pub mod validation;
6
7pub use backend_trait::{
8    CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
9};
10pub use backends::get_backend;
11pub use overrides::TypeOverride;
12
13use scythe_backend::manifest::BackendManifest;
14use scythe_backend::naming::{row_struct_name, to_pascal_case};
15
16use scythe_core::analyzer::{AnalyzedQuery, EnumInfo};
17use scythe_core::catalog::Catalog;
18use scythe_core::errors::{ErrorCode, ScytheError};
19use scythe_core::parser::QueryCommand;
20
21// ---------------------------------------------------------------------------
22// Output types
23// ---------------------------------------------------------------------------
24
25#[derive(Debug, Default)]
26pub struct GeneratedCode {
27    pub query_fn: Option<String>,
28    pub row_struct: Option<String>,
29    pub model_struct: Option<String>,
30    pub enum_def: Option<String>,
31}
32
33// ---------------------------------------------------------------------------
34// Utility (shared across backends)
35// ---------------------------------------------------------------------------
36
37/// Simple singularization: remove trailing 's'.
38pub fn singularize(name: &str) -> String {
39    if let Some(stem) = name.strip_suffix("ies") {
40        format!("{stem}y")
41    } else if name.ends_with("sses")
42        || name.ends_with("shes")
43        || name.ends_with("ches")
44        || name.ends_with("xes")
45        || name.ends_with("zes")
46        || name.ends_with("ses")
47    {
48        name[..name.len() - 2].to_string()
49    } else if name.ends_with('s') && !name.ends_with("ss") {
50        name[..name.len() - 1].to_string()
51    } else {
52        name.to_string()
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Manifest helpers
58// ---------------------------------------------------------------------------
59
60/// Get the manifest for a backend. Defaults to PostgreSQL engine.
61pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
62    let backend = get_backend(backend_name, "postgresql")?;
63    Ok(backend.manifest().clone())
64}
65
66/// Determine the struct name for a query (model struct or row struct).
67fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
68    if let Some(ref table_name) = analyzed.source_table {
69        let singular = singularize(table_name);
70        to_pascal_case(&singular).into_owned()
71    } else {
72        row_struct_name(&analyzed.name, &manifest.naming)
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Public API
78// ---------------------------------------------------------------------------
79
80/// Generate code using a specific backend.
81pub fn generate_with_backend(
82    analyzed: &AnalyzedQuery,
83    backend: &dyn CodegenBackend,
84) -> Result<GeneratedCode, ScytheError> {
85    generate_with_backend_and_overrides(analyzed, backend, &[])
86}
87
88/// Generate code using a specific backend with type overrides.
89pub fn generate_with_backend_and_overrides(
90    analyzed: &AnalyzedQuery,
91    backend: &dyn CodegenBackend,
92    overrides: &[TypeOverride],
93) -> Result<GeneratedCode, ScytheError> {
94    let manifest = backend.manifest();
95    let source_table = analyzed.source_table.as_deref().unwrap_or("");
96    let columns = resolve::resolve_columns(&analyzed.columns, manifest, overrides, source_table)?;
97    let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
98
99    let mut result = GeneratedCode::default();
100
101    // Generate enum definitions for any enum-typed columns
102    // Use the backend-specific enum generation for proper derives
103    let enum_def = generate_enum_defs_via_backend(analyzed, backend)?;
104    if !enum_def.is_empty() {
105        result.enum_def = Some(enum_def);
106    }
107
108    // Generate row/model struct for :one, :opt, and :many commands (not :batch or :grouped).
109    // Grouped queries generate their own parent+child structs via generate_grouped_structs.
110    let needs_row_struct = matches!(
111        analyzed.command,
112        QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
113    );
114    if needs_row_struct && !analyzed.columns.is_empty() {
115        if let Some(ref table_name) = analyzed.source_table {
116            result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
117        } else {
118            result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
119        }
120    }
121
122    // Generate composite type definitions
123    if !analyzed.composites.is_empty() {
124        let mut comp_defs = String::new();
125        for (i, comp) in analyzed.composites.iter().enumerate() {
126            if i > 0 {
127                comp_defs.push_str("\n\n");
128            }
129            comp_defs.push_str(&backend.generate_composite_def(comp)?);
130        }
131        if !comp_defs.is_empty() {
132            if let Some(ref mut existing) = result.model_struct {
133                existing.push_str("\n\n");
134                existing.push_str(&comp_defs);
135            } else {
136                result.model_struct = Some(comp_defs);
137            }
138        }
139    }
140
141    // Generate query function (and for :grouped, also the parent+child structs).
142    let struct_name = determine_struct_name(analyzed, manifest);
143
144    if analyzed.command == QueryCommand::Grouped {
145        // Real grouped codegen: parent struct + child struct + fold-on-client query fn.
146        let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
147            ScytheError::new(
148                ErrorCode::InternalError,
149                format!(
150                    "query '{}' is :grouped but is missing @group_by annotation",
151                    analyzed.name
152                ),
153            )
154        })?;
155
156        // Derive struct names from the manifest's row suffix and struct case.
157        // E.g. "GetUsersWithOrders" + suffix "Row" -> parent "GetUsersWithOrdersRow",
158        // child "GetUsersWithOrdersChildRow".
159        let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
160        let child_struct_name = {
161            let suffix = &manifest.naming.row_suffix;
162            let base = parent_struct_name.trim_end_matches(suffix.as_str());
163            format!("{}Child{}", base, suffix)
164        };
165
166        // Resolve parent and child columns to language-specific types.
167        let parent_cols = resolve::resolve_columns(&group_by.parent_columns, manifest, overrides, source_table)?;
168        let child_cols = resolve::resolve_columns(&group_by.child_columns, manifest, overrides, source_table)?;
169
170        result.row_struct = Some(backend.generate_grouped_structs(
171            &parent_struct_name,
172            &child_struct_name,
173            &parent_cols,
174            &child_cols,
175            &group_by.key_column,
176        )?);
177        result.query_fn = Some(
178            backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
179                analyzed,
180                parent_struct_name: &parent_struct_name,
181                child_struct_name: &child_struct_name,
182                all_columns: &columns,
183                parent_columns: &parent_cols,
184                child_columns: &child_cols,
185                params: &params,
186                key_column: &group_by.key_column,
187            })?,
188        );
189    } else {
190        result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, &params)?);
191    }
192
193    Ok(result)
194}
195
196/// Generate enum definitions via the backend trait.
197fn generate_enum_defs_via_backend(
198    analyzed: &AnalyzedQuery,
199    backend: &dyn CodegenBackend,
200) -> Result<String, ScytheError> {
201    use ahash::AHashSet;
202    use std::fmt::Write;
203
204    let mut out = String::new();
205    let mut seen_enums: AHashSet<String> = AHashSet::new();
206
207    let enum_sources: Vec<&str> = analyzed
208        .columns
209        .iter()
210        .filter_map(|col| col.neutral_type.strip_prefix("enum::"))
211        .chain(
212            analyzed
213                .params
214                .iter()
215                .filter_map(|p| p.neutral_type.strip_prefix("enum::")),
216        )
217        .collect();
218
219    for sql_name in enum_sources {
220        if !seen_enums.insert(sql_name.to_string()) {
221            continue;
222        }
223
224        if !out.is_empty() {
225            let _ = writeln!(out);
226        }
227
228        if let Some(enum_info) = analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
229            out.push_str(&backend.generate_enum_def(enum_info)?);
230        } else {
231            // Generate a stub enum with no variants (for enum types referenced but
232            // not fully defined in the query's EnumInfo list).
233            let stub_info = EnumInfo {
234                sql_name: sql_name.to_string(),
235                values: vec![],
236            };
237            out.push_str(&backend.generate_enum_def(&stub_info)?);
238        }
239    }
240
241    Ok(out)
242}
243
244/// Backward-compatible: generate code using the default sqlx backend.
245pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
246    let backend = get_backend("rust-sqlx", "postgresql")?;
247    generate_with_backend(analyzed, &*backend)
248}
249
250/// Stub for catalog-level codegen. Returns default for now.
251pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
252    Ok(GeneratedCode::default())
253}
254
255/// Generate a single enum definition using a specific backend.
256pub fn generate_single_enum_def_with_backend(
257    enum_info: &EnumInfo,
258    backend: &dyn CodegenBackend,
259) -> Result<String, ScytheError> {
260    backend.generate_enum_def(enum_info)
261}
262
263/// Backward-compatible: generate a single enum definition (sqlx backend).
264/// Uses the manifest directly for backward compatibility with existing callers.
265pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
266    // Reproduce the old behavior exactly using the sqlx backend's logic
267    use scythe_backend::naming::{enum_type_name, enum_variant_name};
268    use std::fmt::Write;
269
270    let mut out = String::with_capacity(256);
271    let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
272
273    let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
274    let _ = writeln!(
275        out,
276        "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
277        enum_info.sql_name
278    );
279    let _ = writeln!(out, "pub enum {type_name} {{");
280
281    for value in &enum_info.values {
282        let variant = enum_variant_name(value, &manifest.naming);
283        let _ = writeln!(out, "    {variant},");
284    }
285
286    let _ = write!(out, "}}");
287    out
288}
289
290/// Backward-compatible: load the default sqlx manifest.
291pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
292    let b = backends::sqlx::SqlxBackend::new("postgresql")?;
293    Ok(b.manifest().clone())
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig};
304    use scythe_core::parser::QueryCommand;
305
306    fn make_query(
307        name: &str,
308        command: QueryCommand,
309        sql: &str,
310        columns: Vec<AnalyzedColumn>,
311        params: Vec<AnalyzedParam>,
312    ) -> AnalyzedQuery {
313        AnalyzedQuery {
314            name: name.to_string(),
315            command,
316            sql: sql.to_string(),
317            columns,
318            params,
319            deprecated: None,
320            source_table: None,
321            composites: Vec::new(),
322            enums: Vec::new(),
323            optional_params: Vec::new(),
324            group_by: None,
325            custom: Vec::new(),
326        }
327    }
328
329    #[test]
330    fn test_generate_select_many() {
331        let query = make_query(
332            "ListUsers",
333            QueryCommand::Many,
334            "SELECT id, name, email FROM users",
335            vec![
336                AnalyzedColumn {
337                    name: "id".to_string(),
338                    neutral_type: "int32".to_string(),
339                    nullable: false,
340                },
341                AnalyzedColumn {
342                    name: "name".to_string(),
343                    neutral_type: "string".to_string(),
344                    nullable: false,
345                },
346                AnalyzedColumn {
347                    name: "email".to_string(),
348                    neutral_type: "string".to_string(),
349                    nullable: true,
350                },
351            ],
352            vec![],
353        );
354
355        let result = generate(&query).unwrap();
356
357        let row_struct = result.row_struct.unwrap();
358        assert!(row_struct.contains("pub struct ListUsersRow"));
359        assert!(row_struct.contains("pub id: i32"));
360        assert!(row_struct.contains("pub name: String"));
361        assert!(row_struct.contains("pub email: Option<String>"));
362
363        let query_fn = result.query_fn.unwrap();
364        assert!(query_fn.contains("pub async fn list_users("));
365        assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
366        assert!(query_fn.contains(".fetch_all(pool)"));
367    }
368
369    #[test]
370    fn test_generate_select_one_with_param() {
371        let query = make_query(
372            "GetUser",
373            QueryCommand::One,
374            "SELECT id, name FROM users WHERE id = $1",
375            vec![
376                AnalyzedColumn {
377                    name: "id".to_string(),
378                    neutral_type: "int32".to_string(),
379                    nullable: false,
380                },
381                AnalyzedColumn {
382                    name: "name".to_string(),
383                    neutral_type: "string".to_string(),
384                    nullable: false,
385                },
386            ],
387            vec![AnalyzedParam {
388                name: "id".to_string(),
389                neutral_type: "int32".to_string(),
390                nullable: false,
391                position: 1,
392            }],
393        );
394
395        let result = generate(&query).unwrap();
396
397        let query_fn = result.query_fn.unwrap();
398        assert!(query_fn.contains("pub async fn get_user("));
399        assert!(query_fn.contains("id: i32"));
400        assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
401        assert!(query_fn.contains(".fetch_one(pool)"));
402    }
403
404    #[test]
405    fn test_generate_exec() {
406        let query = make_query(
407            "DeleteUser",
408            QueryCommand::Exec,
409            "DELETE FROM users WHERE id = $1",
410            vec![],
411            vec![AnalyzedParam {
412                name: "id".to_string(),
413                neutral_type: "int32".to_string(),
414                nullable: false,
415                position: 1,
416            }],
417        );
418
419        let result = generate(&query).unwrap();
420
421        assert!(result.row_struct.is_none());
422
423        let query_fn = result.query_fn.unwrap();
424        assert!(query_fn.contains("pub async fn delete_user("));
425        assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
426        assert!(query_fn.contains(".execute(pool)"));
427    }
428
429    #[test]
430    fn test_generate_with_enum_column() {
431        let query = make_query(
432            "GetUserStatus",
433            QueryCommand::One,
434            "SELECT id, status FROM users WHERE id = $1",
435            vec![
436                AnalyzedColumn {
437                    name: "id".to_string(),
438                    neutral_type: "int32".to_string(),
439                    nullable: false,
440                },
441                AnalyzedColumn {
442                    name: "status".to_string(),
443                    neutral_type: "enum::user_status".to_string(),
444                    nullable: false,
445                },
446            ],
447            vec![AnalyzedParam {
448                name: "id".to_string(),
449                neutral_type: "int32".to_string(),
450                nullable: false,
451                position: 1,
452            }],
453        );
454
455        let result = generate(&query).unwrap();
456
457        assert!(result.enum_def.is_some());
458        let enum_def = result.enum_def.unwrap();
459        assert!(enum_def.contains("pub enum UserStatus"));
460        assert!(enum_def.contains("type_name = \"user_status\""));
461
462        let row_struct = result.row_struct.unwrap();
463        assert!(row_struct.contains("pub status: UserStatus"));
464    }
465
466    #[test]
467    fn test_generate_from_catalog_returns_default() {
468        let catalog = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER);"]).unwrap();
469        let result = generate_from_catalog(&catalog).unwrap();
470        assert!(result.query_fn.is_none());
471        assert!(result.row_struct.is_none());
472    }
473
474    #[test]
475    fn test_singularize_basic() {
476        assert_eq!(singularize("users"), "user");
477        assert_eq!(singularize("orders"), "order");
478        assert_eq!(singularize("posts"), "post");
479    }
480
481    #[test]
482    fn test_singularize_ies() {
483        assert_eq!(singularize("categories"), "category");
484        assert_eq!(singularize("entries"), "entry");
485    }
486
487    #[test]
488    fn test_singularize_sses() {
489        assert_eq!(singularize("addresses"), "address");
490        assert_eq!(singularize("classes"), "class");
491    }
492
493    #[test]
494    fn test_singularize_no_change() {
495        assert_eq!(singularize("status"), "statu");
496        assert_eq!(singularize("boss"), "boss");
497        assert_eq!(singularize("address"), "address");
498    }
499
500    #[test]
501    fn test_singularize_shes_ches_xes() {
502        assert_eq!(singularize("batches"), "batch");
503        assert_eq!(singularize("boxes"), "box");
504        assert_eq!(singularize("wishes"), "wish");
505    }
506
507    // -----------------------------------------------------------------------
508    // Grouped query codegen tests (issue #55 reference example)
509    // Schema: users(id, name, email), orders(id, user_id, total, created_at)
510    // Query: SELECT u.id, u.name, u.email, o.id AS order_id, o.total,
511    //               o.created_at AS order_date FROM users u JOIN orders o ...
512    // @group_by users.id
513    // -----------------------------------------------------------------------
514
515    fn make_grouped_query() -> AnalyzedQuery {
516        let parent_cols = vec![
517            AnalyzedColumn {
518                name: "id".to_string(),
519                neutral_type: "int32".to_string(),
520                nullable: false,
521            },
522            AnalyzedColumn {
523                name: "name".to_string(),
524                neutral_type: "string".to_string(),
525                nullable: false,
526            },
527            AnalyzedColumn {
528                name: "email".to_string(),
529                neutral_type: "string".to_string(),
530                nullable: false,
531            },
532        ];
533        let child_cols = vec![
534            AnalyzedColumn {
535                name: "order_id".to_string(),
536                neutral_type: "int32".to_string(),
537                nullable: false,
538            },
539            AnalyzedColumn {
540                name: "total".to_string(),
541                neutral_type: "decimal".to_string(),
542                nullable: true,
543            },
544            AnalyzedColumn {
545                name: "order_date".to_string(),
546                neutral_type: "datetime".to_string(),
547                nullable: false,
548            },
549        ];
550        let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
551
552        AnalyzedQuery {
553            name: "GetUsersWithOrders".to_string(),
554            command: QueryCommand::Grouped,
555            sql: "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
556                  SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
557                  FROM users u\n\
558                  JOIN orders o ON o.user_id = u.id"
559                .to_string(),
560            columns: all_cols,
561            params: vec![],
562            deprecated: None,
563            source_table: None,
564            composites: vec![],
565            enums: vec![],
566            optional_params: vec![],
567            group_by: Some(GroupByConfig {
568                table: "users".to_string(),
569                key_column: "id".to_string(),
570                parent_columns: parent_cols,
571                child_columns: child_cols,
572            }),
573            custom: vec![],
574        }
575    }
576
577    #[test]
578    fn test_grouped_sqlx_structs() {
579        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
580        let query = make_grouped_query();
581        let result = generate_with_backend(&query, &*backend).unwrap();
582
583        // Both child and parent structs packed into row_struct (child first)
584        let row_struct = result.row_struct.as_deref().unwrap();
585        assert!(
586            row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
587            "missing child struct; got:\n{row_struct}"
588        );
589        assert!(
590            row_struct.contains("pub order_id: i32"),
591            "child struct missing order_id field; got:\n{row_struct}"
592        );
593        assert!(
594            row_struct.contains("pub struct GetUsersWithOrdersRow"),
595            "missing parent struct; got:\n{row_struct}"
596        );
597        assert!(
598            row_struct.contains("pub id: i32"),
599            "parent struct missing id field; got:\n{row_struct}"
600        );
601        assert!(
602            row_struct.contains("pub name: String"),
603            "parent struct missing name field; got:\n{row_struct}"
604        );
605        assert!(
606            row_struct.contains("pub email: String"),
607            "parent struct missing email field; got:\n{row_struct}"
608        );
609        assert!(
610            row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
611            "parent struct missing children field; got:\n{row_struct}"
612        );
613        // Child struct must appear BEFORE parent struct (no forward references)
614        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
615        let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
616        assert!(
617            child_pos < parent_pos,
618            "child struct must be defined before parent struct"
619        );
620
621        // No flat struct should exist for grouped queries
622        assert!(
623            result.model_struct.is_none(),
624            "grouped should not produce a model_struct"
625        );
626    }
627
628    #[test]
629    fn test_grouped_sqlx_query_fn() {
630        let backend = get_backend("rust-sqlx", "postgresql").unwrap();
631        let query = make_grouped_query();
632        let result = generate_with_backend(&query, &*backend).unwrap();
633
634        let query_fn = result.query_fn.as_deref().unwrap();
635        assert!(
636            query_fn.contains("pub async fn get_users_with_orders("),
637            "missing fn; got:\n{query_fn}"
638        );
639        assert!(
640            query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
641            "wrong return type; got:\n{query_fn}"
642        );
643        assert!(
644            query_fn.contains("sqlx::query!("),
645            "grouped fn must use sqlx::query!; got:\n{query_fn}"
646        );
647        assert!(
648            query_fn.contains("GetUsersWithOrdersChildRow {"),
649            "fn must construct child struct; got:\n{query_fn}"
650        );
651        assert!(
652            query_fn.contains("children: vec![child]"),
653            "fn must initialize children vec; got:\n{query_fn}"
654        );
655        assert!(
656            query_fn.contains("parent.children.push(child)"),
657            "fn must fold child into existing parent; got:\n{query_fn}"
658        );
659        assert!(
660            query_fn.contains("Ok(result)"),
661            "fn must return result; got:\n{query_fn}"
662        );
663    }
664
665    #[test]
666    fn test_grouped_python_asyncpg_structs() {
667        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
668        let query = make_grouped_query();
669        let result = generate_with_backend(&query, &*backend).unwrap();
670
671        let row_struct = result.row_struct.as_deref().unwrap();
672        assert!(
673            row_struct.contains("class GetUsersWithOrdersChildRow"),
674            "missing child class; got:\n{row_struct}"
675        );
676        assert!(
677            row_struct.contains("order_id: int"),
678            "child class missing order_id field; got:\n{row_struct}"
679        );
680        assert!(
681            row_struct.contains("class GetUsersWithOrdersRow"),
682            "missing parent class; got:\n{row_struct}"
683        );
684        assert!(
685            row_struct.contains("id: int"),
686            "parent class missing id field; got:\n{row_struct}"
687        );
688        assert!(
689            row_struct.contains("name: str"),
690            "parent class missing name field; got:\n{row_struct}"
691        );
692        assert!(
693            row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
694            "parent class missing children field; got:\n{row_struct}"
695        );
696        // Child class must appear before parent class
697        let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
698        let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
699        assert!(
700            child_pos < parent_pos,
701            "child class must be defined before parent class"
702        );
703    }
704
705    #[test]
706    fn test_grouped_python_asyncpg_query_fn() {
707        let backend = get_backend("python-asyncpg", "postgresql").unwrap();
708        let query = make_grouped_query();
709        let result = generate_with_backend(&query, &*backend).unwrap();
710
711        let query_fn = result.query_fn.as_deref().unwrap();
712        assert!(
713            query_fn.contains("async def get_users_with_orders(conn: Connection)"),
714            "missing fn; got:\n{query_fn}"
715        );
716        assert!(
717            query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
718            "wrong return type; got:\n{query_fn}"
719        );
720        assert!(
721            query_fn.contains("conn.fetch("),
722            "fn must use conn.fetch; got:\n{query_fn}"
723        );
724        assert!(
725            query_fn.contains("GetUsersWithOrdersChildRow("),
726            "fn must construct child class; got:\n{query_fn}"
727        );
728        assert!(
729            query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
730            "fn must construct parent with **kwargs; got:\n{query_fn}"
731        );
732        assert!(
733            query_fn.contains("_index"),
734            "fn must use index dict for O(1) lookup; got:\n{query_fn}"
735        );
736    }
737
738    /// Minimal backend that implements only the required trait methods and
739    /// leaves the grouped methods as their default (erroring) impl. Every
740    /// shipped backend now supports grouped codegen, so the "not yet supported"
741    /// path can only be exercised by a backend that has not opted in — this stub
742    /// stands in for such a backend so the contract stays covered.
743    struct StubBackend {
744        manifest: BackendManifest,
745    }
746
747    impl CodegenBackend for StubBackend {
748        fn name(&self) -> &str {
749            "stub-backend"
750        }
751        fn manifest(&self) -> &BackendManifest {
752            &self.manifest
753        }
754        fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
755            Ok(String::new())
756        }
757        fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
758            Ok(String::new())
759        }
760        fn generate_query_fn(
761            &self,
762            _analyzed: &AnalyzedQuery,
763            _struct_name: &str,
764            _columns: &[ResolvedColumn],
765            _params: &[ResolvedParam],
766        ) -> Result<String, ScytheError> {
767            Ok(String::new())
768        }
769        fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
770            Ok(String::new())
771        }
772        fn generate_composite_def(
773            &self,
774            _composite: &scythe_core::analyzer::CompositeInfo,
775        ) -> Result<String, ScytheError> {
776            Ok(String::new())
777        }
778    }
779
780    #[test]
781    fn test_grouped_unsupported_backend_returns_clear_error() {
782        // A backend that does not override the grouped trait methods must surface
783        // a clear, backend-named error rather than panicking. A stub keeps this
784        // valid even though every shipped backend now implements grouped codegen.
785        let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
786        let backend = StubBackend { manifest };
787        let query = make_grouped_query();
788        let result = generate_with_backend(&query, &backend);
789        assert!(
790            result.is_err(),
791            "stub backend should return an error for grouped queries"
792        );
793        let err = result.unwrap_err();
794        assert!(
795            err.message.contains("not yet supported"),
796            "error should contain 'not yet supported', got: {}",
797            err.message
798        );
799        assert!(
800            err.message.contains("stub-backend"),
801            "error should name the backend, got: {}",
802            err.message
803        );
804    }
805
806    #[test]
807    fn test_tokio_postgres_backend_basic() {
808        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
809
810        let query = make_query(
811            "ListUsers",
812            QueryCommand::Many,
813            "SELECT id, name FROM users",
814            vec![
815                AnalyzedColumn {
816                    name: "id".to_string(),
817                    neutral_type: "int32".to_string(),
818                    nullable: false,
819                },
820                AnalyzedColumn {
821                    name: "name".to_string(),
822                    neutral_type: "string".to_string(),
823                    nullable: false,
824                },
825            ],
826            vec![],
827        );
828
829        let result = generate_with_backend(&query, &*backend).unwrap();
830
831        let row_struct = result.row_struct.unwrap();
832        assert!(row_struct.contains("pub struct ListUsersRow"));
833        assert!(row_struct.contains("pub id: i32"));
834        assert!(row_struct.contains("pub name: String"));
835        assert!(row_struct.contains("from_row"));
836        assert!(row_struct.contains("tokio_postgres::Row"));
837        // Should NOT contain sqlx
838        assert!(!row_struct.contains("sqlx"));
839
840        let query_fn = result.query_fn.unwrap();
841        assert!(query_fn.contains("pub async fn list_users("));
842        assert!(query_fn.contains("tokio_postgres::GenericClient"));
843        assert!(query_fn.contains("tokio_postgres::Error"));
844        assert!(!query_fn.contains("sqlx"));
845    }
846
847    #[test]
848    fn test_tokio_postgres_enum() {
849        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
850
851        let enum_info = scythe_core::analyzer::EnumInfo {
852            sql_name: "user_status".to_string(),
853            values: vec!["active".to_string(), "inactive".to_string()],
854        };
855
856        let def = backend.generate_enum_def(&enum_info).unwrap();
857        assert!(def.contains("pub enum UserStatus"));
858        assert!(def.contains("Active"));
859        assert!(def.contains("Inactive"));
860        assert!(def.contains("impl std::fmt::Display"));
861        assert!(def.contains("impl std::str::FromStr"));
862        // Should NOT contain sqlx
863        assert!(!def.contains("sqlx"));
864    }
865
866    #[test]
867    fn test_sql_with_least_coalesce_sum_preserved() {
868        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
869
870        let query = make_query(
871            "GetBillingAggregates",
872            QueryCommand::One,
873            "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
874            vec![AnalyzedColumn {
875                name: "aggregated_free_pages".to_string(),
876                neutral_type: "int64".to_string(),
877                nullable: false,
878            }],
879            vec![AnalyzedParam {
880                name: "customer_id".to_string(),
881                neutral_type: "uuid".to_string(),
882                nullable: false,
883                position: 1,
884            }],
885        );
886
887        let result = generate_with_backend(&query, &*backend).unwrap();
888        let query_fn = result.query_fn.unwrap();
889
890        // The SQL should preserve the original casing of function names (LEAST, COALESCE, SUM)
891        // and the alias keyword (as)
892        assert!(
893            query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
894            "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
895        );
896        assert!(
897            query_fn.contains("as aggregated_free_pages"),
898            "SQL should preserve alias keyword (as)"
899        );
900    }
901
902    #[test]
903    fn test_generated_rust_code_structure() {
904        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
905
906        let query = make_query(
907            "GetUser",
908            QueryCommand::One,
909            "SELECT id, name FROM users WHERE id = $1",
910            vec![
911                AnalyzedColumn {
912                    name: "id".to_string(),
913                    neutral_type: "int32".to_string(),
914                    nullable: false,
915                },
916                AnalyzedColumn {
917                    name: "name".to_string(),
918                    neutral_type: "string".to_string(),
919                    nullable: false,
920                },
921            ],
922            vec![AnalyzedParam {
923                name: "id".to_string(),
924                neutral_type: "int32".to_string(),
925                nullable: false,
926                position: 1,
927            }],
928        );
929
930        let result = generate_with_backend(&query, &*backend).unwrap();
931        let query_fn = result.query_fn.unwrap();
932
933        // Verify basic structure is present
934        assert!(query_fn.contains("pub async fn get_user("));
935        assert!(query_fn.contains("tokio_postgres::GenericClient"));
936        assert!(query_fn.contains("GetUserRow"));
937
938        // Verify SQL is preserved in the code
939        assert!(query_fn.contains("SELECT id, name FROM users"));
940    }
941}