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, 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!(
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 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 let struct_name = determine_struct_name(analyzed, manifest);
143
144 if analyzed.command == QueryCommand::Grouped {
145 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 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 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: ¶ms,
186 key_column: &group_by.key_column,
187 })?,
188 );
189 } else {
190 result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
191 }
192
193 Ok(result)
194}
195
196fn 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 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
244pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
246 let backend = get_backend("rust-sqlx", "postgresql")?;
247 generate_with_backend(analyzed, &*backend)
248}
249
250pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
252 Ok(GeneratedCode::default())
253}
254
255pub 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
263pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
266 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
290pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
292 let b = backends::sqlx::SqlxBackend::new("postgresql")?;
293 Ok(b.manifest().clone())
294}
295
296#[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 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 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 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 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 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 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 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 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 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 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 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 assert!(query_fn.contains("SELECT id, name FROM users"));
940 }
941}