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