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::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)
109    let needs_row_struct = matches!(
110        analyzed.command,
111        QueryCommand::One | QueryCommand::Opt | QueryCommand::Many | QueryCommand::Grouped
112    );
113    if needs_row_struct && !analyzed.columns.is_empty() {
114        if let Some(ref table_name) = analyzed.source_table {
115            result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
116        } else {
117            result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
118        }
119    }
120
121    // Generate composite type definitions
122    if !analyzed.composites.is_empty() {
123        let mut comp_defs = String::new();
124        for (i, comp) in analyzed.composites.iter().enumerate() {
125            if i > 0 {
126                comp_defs.push_str("\n\n");
127            }
128            comp_defs.push_str(&backend.generate_composite_def(comp)?);
129        }
130        if !comp_defs.is_empty() {
131            if let Some(ref mut existing) = result.model_struct {
132                existing.push_str("\n\n");
133                existing.push_str(&comp_defs);
134            } else {
135                result.model_struct = Some(comp_defs);
136            }
137        }
138    }
139
140    // Generate query function
141    let struct_name = determine_struct_name(analyzed, manifest);
142
143    // For :grouped, delegate to the backend as :many for now.
144    // Full grouped codegen (parent + child structs, grouping logic) will come in a later phase.
145    if analyzed.command == QueryCommand::Grouped {
146        let many_proxy = AnalyzedQuery {
147            name: analyzed.name.clone(),
148            command: QueryCommand::Many,
149            sql: analyzed.sql.clone(),
150            columns: analyzed.columns.clone(),
151            params: analyzed.params.clone(),
152            deprecated: analyzed.deprecated.clone(),
153            source_table: analyzed.source_table.clone(),
154            composites: analyzed.composites.clone(),
155            enums: analyzed.enums.clone(),
156            optional_params: analyzed.optional_params.clone(),
157            group_by: analyzed.group_by.clone(),
158            custom: analyzed.custom.clone(),
159        };
160        result.query_fn =
161            Some(backend.generate_query_fn(&many_proxy, &struct_name, &columns, &params)?);
162    } else {
163        result.query_fn =
164            Some(backend.generate_query_fn(analyzed, &struct_name, &columns, &params)?);
165    }
166
167    Ok(result)
168}
169
170/// Generate enum definitions via the backend trait.
171fn generate_enum_defs_via_backend(
172    analyzed: &AnalyzedQuery,
173    backend: &dyn CodegenBackend,
174) -> Result<String, ScytheError> {
175    use ahash::AHashSet;
176    use std::fmt::Write;
177
178    let mut out = String::new();
179    let mut seen_enums: AHashSet<String> = AHashSet::new();
180
181    let enum_sources: Vec<&str> = analyzed
182        .columns
183        .iter()
184        .filter_map(|col| col.neutral_type.strip_prefix("enum::"))
185        .chain(
186            analyzed
187                .params
188                .iter()
189                .filter_map(|p| p.neutral_type.strip_prefix("enum::")),
190        )
191        .collect();
192
193    for sql_name in enum_sources {
194        if !seen_enums.insert(sql_name.to_string()) {
195            continue;
196        }
197
198        if !out.is_empty() {
199            let _ = writeln!(out);
200        }
201
202        if let Some(enum_info) = analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
203            out.push_str(&backend.generate_enum_def(enum_info)?);
204        } else {
205            // Generate a stub enum with no variants (for enum types referenced but
206            // not fully defined in the query's EnumInfo list).
207            let stub_info = EnumInfo {
208                sql_name: sql_name.to_string(),
209                values: vec![],
210            };
211            out.push_str(&backend.generate_enum_def(&stub_info)?);
212        }
213    }
214
215    Ok(out)
216}
217
218/// Backward-compatible: generate code using the default sqlx backend.
219pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
220    let backend = get_backend("rust-sqlx", "postgresql")?;
221    generate_with_backend(analyzed, &*backend)
222}
223
224/// Stub for catalog-level codegen. Returns default for now.
225pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
226    Ok(GeneratedCode::default())
227}
228
229/// Generate a single enum definition using a specific backend.
230pub fn generate_single_enum_def_with_backend(
231    enum_info: &EnumInfo,
232    backend: &dyn CodegenBackend,
233) -> Result<String, ScytheError> {
234    backend.generate_enum_def(enum_info)
235}
236
237/// Backward-compatible: generate a single enum definition (sqlx backend).
238/// Uses the manifest directly for backward compatibility with existing callers.
239pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
240    // Reproduce the old behavior exactly using the sqlx backend's logic
241    use scythe_backend::naming::{enum_type_name, enum_variant_name};
242    use std::fmt::Write;
243
244    let mut out = String::with_capacity(256);
245    let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
246
247    let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
248    let _ = writeln!(
249        out,
250        "#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
251        enum_info.sql_name
252    );
253    let _ = writeln!(out, "pub enum {type_name} {{");
254
255    for value in &enum_info.values {
256        let variant = enum_variant_name(value, &manifest.naming);
257        let _ = writeln!(out, "    {variant},");
258    }
259
260    let _ = write!(out, "}}");
261    out
262}
263
264/// Backward-compatible: load the default sqlx manifest.
265pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
266    let b = backends::sqlx::SqlxBackend::new("postgresql")?;
267    Ok(b.manifest().clone())
268}
269
270// ---------------------------------------------------------------------------
271// Tests
272// ---------------------------------------------------------------------------
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
278    use scythe_core::parser::QueryCommand;
279
280    fn make_query(
281        name: &str,
282        command: QueryCommand,
283        sql: &str,
284        columns: Vec<AnalyzedColumn>,
285        params: Vec<AnalyzedParam>,
286    ) -> AnalyzedQuery {
287        AnalyzedQuery {
288            name: name.to_string(),
289            command,
290            sql: sql.to_string(),
291            columns,
292            params,
293            deprecated: None,
294            source_table: None,
295            composites: Vec::new(),
296            enums: Vec::new(),
297            optional_params: Vec::new(),
298            group_by: None,
299            custom: Vec::new(),
300        }
301    }
302
303    #[test]
304    fn test_generate_select_many() {
305        let query = make_query(
306            "ListUsers",
307            QueryCommand::Many,
308            "SELECT id, name, email FROM users",
309            vec![
310                AnalyzedColumn {
311                    name: "id".to_string(),
312                    neutral_type: "int32".to_string(),
313                    nullable: false,
314                },
315                AnalyzedColumn {
316                    name: "name".to_string(),
317                    neutral_type: "string".to_string(),
318                    nullable: false,
319                },
320                AnalyzedColumn {
321                    name: "email".to_string(),
322                    neutral_type: "string".to_string(),
323                    nullable: true,
324                },
325            ],
326            vec![],
327        );
328
329        let result = generate(&query).unwrap();
330
331        let row_struct = result.row_struct.unwrap();
332        assert!(row_struct.contains("pub struct ListUsersRow"));
333        assert!(row_struct.contains("pub id: i32"));
334        assert!(row_struct.contains("pub name: String"));
335        assert!(row_struct.contains("pub email: Option<String>"));
336
337        let query_fn = result.query_fn.unwrap();
338        assert!(query_fn.contains("pub async fn list_users("));
339        assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
340        assert!(query_fn.contains(".fetch_all(pool)"));
341    }
342
343    #[test]
344    fn test_generate_select_one_with_param() {
345        let query = make_query(
346            "GetUser",
347            QueryCommand::One,
348            "SELECT id, name FROM users WHERE id = $1",
349            vec![
350                AnalyzedColumn {
351                    name: "id".to_string(),
352                    neutral_type: "int32".to_string(),
353                    nullable: false,
354                },
355                AnalyzedColumn {
356                    name: "name".to_string(),
357                    neutral_type: "string".to_string(),
358                    nullable: false,
359                },
360            ],
361            vec![AnalyzedParam {
362                name: "id".to_string(),
363                neutral_type: "int32".to_string(),
364                nullable: false,
365                position: 1,
366            }],
367        );
368
369        let result = generate(&query).unwrap();
370
371        let query_fn = result.query_fn.unwrap();
372        assert!(query_fn.contains("pub async fn get_user("));
373        assert!(query_fn.contains("id: i32"));
374        assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
375        assert!(query_fn.contains(".fetch_one(pool)"));
376    }
377
378    #[test]
379    fn test_generate_exec() {
380        let query = make_query(
381            "DeleteUser",
382            QueryCommand::Exec,
383            "DELETE FROM users WHERE id = $1",
384            vec![],
385            vec![AnalyzedParam {
386                name: "id".to_string(),
387                neutral_type: "int32".to_string(),
388                nullable: false,
389                position: 1,
390            }],
391        );
392
393        let result = generate(&query).unwrap();
394
395        assert!(result.row_struct.is_none());
396
397        let query_fn = result.query_fn.unwrap();
398        assert!(query_fn.contains("pub async fn delete_user("));
399        assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
400        assert!(query_fn.contains(".execute(pool)"));
401    }
402
403    #[test]
404    fn test_generate_with_enum_column() {
405        let query = make_query(
406            "GetUserStatus",
407            QueryCommand::One,
408            "SELECT id, status FROM users WHERE id = $1",
409            vec![
410                AnalyzedColumn {
411                    name: "id".to_string(),
412                    neutral_type: "int32".to_string(),
413                    nullable: false,
414                },
415                AnalyzedColumn {
416                    name: "status".to_string(),
417                    neutral_type: "enum::user_status".to_string(),
418                    nullable: false,
419                },
420            ],
421            vec![AnalyzedParam {
422                name: "id".to_string(),
423                neutral_type: "int32".to_string(),
424                nullable: false,
425                position: 1,
426            }],
427        );
428
429        let result = generate(&query).unwrap();
430
431        assert!(result.enum_def.is_some());
432        let enum_def = result.enum_def.unwrap();
433        assert!(enum_def.contains("pub enum UserStatus"));
434        assert!(enum_def.contains("type_name = \"user_status\""));
435
436        let row_struct = result.row_struct.unwrap();
437        assert!(row_struct.contains("pub status: UserStatus"));
438    }
439
440    #[test]
441    fn test_generate_from_catalog_returns_default() {
442        let catalog = Catalog::from_ddl(&["CREATE TABLE t (id INTEGER);"]).unwrap();
443        let result = generate_from_catalog(&catalog).unwrap();
444        assert!(result.query_fn.is_none());
445        assert!(result.row_struct.is_none());
446    }
447
448    #[test]
449    fn test_singularize_basic() {
450        assert_eq!(singularize("users"), "user");
451        assert_eq!(singularize("orders"), "order");
452        assert_eq!(singularize("posts"), "post");
453    }
454
455    #[test]
456    fn test_singularize_ies() {
457        assert_eq!(singularize("categories"), "category");
458        assert_eq!(singularize("entries"), "entry");
459    }
460
461    #[test]
462    fn test_singularize_sses() {
463        assert_eq!(singularize("addresses"), "address");
464        assert_eq!(singularize("classes"), "class");
465    }
466
467    #[test]
468    fn test_singularize_no_change() {
469        assert_eq!(singularize("status"), "statu");
470        assert_eq!(singularize("boss"), "boss");
471        assert_eq!(singularize("address"), "address");
472    }
473
474    #[test]
475    fn test_singularize_shes_ches_xes() {
476        assert_eq!(singularize("batches"), "batch");
477        assert_eq!(singularize("boxes"), "box");
478        assert_eq!(singularize("wishes"), "wish");
479    }
480
481    #[test]
482    fn test_tokio_postgres_backend_basic() {
483        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
484
485        let query = make_query(
486            "ListUsers",
487            QueryCommand::Many,
488            "SELECT id, name FROM users",
489            vec![
490                AnalyzedColumn {
491                    name: "id".to_string(),
492                    neutral_type: "int32".to_string(),
493                    nullable: false,
494                },
495                AnalyzedColumn {
496                    name: "name".to_string(),
497                    neutral_type: "string".to_string(),
498                    nullable: false,
499                },
500            ],
501            vec![],
502        );
503
504        let result = generate_with_backend(&query, &*backend).unwrap();
505
506        let row_struct = result.row_struct.unwrap();
507        assert!(row_struct.contains("pub struct ListUsersRow"));
508        assert!(row_struct.contains("pub id: i32"));
509        assert!(row_struct.contains("pub name: String"));
510        assert!(row_struct.contains("from_row"));
511        assert!(row_struct.contains("tokio_postgres::Row"));
512        // Should NOT contain sqlx
513        assert!(!row_struct.contains("sqlx"));
514
515        let query_fn = result.query_fn.unwrap();
516        assert!(query_fn.contains("pub async fn list_users("));
517        assert!(query_fn.contains("tokio_postgres::GenericClient"));
518        assert!(query_fn.contains("tokio_postgres::Error"));
519        assert!(!query_fn.contains("sqlx"));
520    }
521
522    #[test]
523    fn test_tokio_postgres_enum() {
524        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
525
526        let enum_info = scythe_core::analyzer::EnumInfo {
527            sql_name: "user_status".to_string(),
528            values: vec!["active".to_string(), "inactive".to_string()],
529        };
530
531        let def = backend.generate_enum_def(&enum_info).unwrap();
532        assert!(def.contains("pub enum UserStatus"));
533        assert!(def.contains("Active"));
534        assert!(def.contains("Inactive"));
535        assert!(def.contains("impl std::fmt::Display"));
536        assert!(def.contains("impl std::str::FromStr"));
537        // Should NOT contain sqlx
538        assert!(!def.contains("sqlx"));
539    }
540
541    #[test]
542    fn test_sql_with_least_coalesce_sum_preserved() {
543        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
544
545        let query = make_query(
546            "GetBillingAggregates",
547            QueryCommand::One,
548            "SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
549            vec![AnalyzedColumn {
550                name: "aggregated_free_pages".to_string(),
551                neutral_type: "int64".to_string(),
552                nullable: false,
553            }],
554            vec![AnalyzedParam {
555                name: "customer_id".to_string(),
556                neutral_type: "uuid".to_string(),
557                nullable: false,
558                position: 1,
559            }],
560        );
561
562        let result = generate_with_backend(&query, &*backend).unwrap();
563        let query_fn = result.query_fn.unwrap();
564
565        // The SQL should preserve the original casing of function names (LEAST, COALESCE, SUM)
566        // and the alias keyword (as)
567        assert!(
568            query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
569            "SQL should preserve original LEAST/COALESCE/SUM function names and casing"
570        );
571        assert!(
572            query_fn.contains("as aggregated_free_pages"),
573            "SQL should preserve alias keyword (as)"
574        );
575    }
576
577    #[test]
578    fn test_generated_rust_code_structure() {
579        let backend = get_backend("tokio-postgres", "postgresql").unwrap();
580
581        let query = make_query(
582            "GetUser",
583            QueryCommand::One,
584            "SELECT id, name FROM users WHERE id = $1",
585            vec![
586                AnalyzedColumn {
587                    name: "id".to_string(),
588                    neutral_type: "int32".to_string(),
589                    nullable: false,
590                },
591                AnalyzedColumn {
592                    name: "name".to_string(),
593                    neutral_type: "string".to_string(),
594                    nullable: false,
595                },
596            ],
597            vec![AnalyzedParam {
598                name: "id".to_string(),
599                neutral_type: "int32".to_string(),
600                nullable: false,
601                position: 1,
602            }],
603        );
604
605        let result = generate_with_backend(&query, &*backend).unwrap();
606        let query_fn = result.query_fn.unwrap();
607
608        // Verify basic structure is present
609        assert!(query_fn.contains("pub async fn get_user("));
610        assert!(query_fn.contains("tokio_postgres::GenericClient"));
611        assert!(query_fn.contains("GetUserRow"));
612
613        // Verify SQL is preserved in the code
614        assert!(query_fn.contains("SELECT id, name FROM users"));
615    }
616}