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#[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
33pub 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
56pub 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
66fn 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
76pub 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
88pub 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 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 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 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 let struct_name = determine_struct_name(analyzed, manifest);
142
143 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, ¶ms)?);
162 } else {
163 result.query_fn =
164 Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
165 }
166
167 Ok(result)
168}
169
170fn 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 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
218pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
220 let backend = get_backend("rust-sqlx", "postgresql")?;
221 generate_with_backend(analyzed, &*backend)
222}
223
224pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
226 Ok(GeneratedCode::default())
227}
228
229pub 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
237pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
240 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
264pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
266 let b = backends::sqlx::SqlxBackend::new("postgresql")?;
267 Ok(b.manifest().clone())
268}
269
270#[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 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 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 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 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 assert!(query_fn.contains("SELECT id, name FROM users"));
615 }
616}