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
29pub 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
48pub 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
54fn 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
64pub 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
72pub 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: ¶ms,
159 key_column: &group_by.key_column,
160 })?,
161 );
162 } else {
163 result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
164 }
165
166 Ok(result)
167}
168
169fn 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
215pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
217 let backend = get_backend("rust-sqlx", "postgresql")?;
218 generate_with_backend(analyzed, &*backend)
219}
220
221pub fn generate_from_catalog(_catalog: &Catalog) -> Result<GeneratedCode, ScytheError> {
223 Ok(GeneratedCode::default())
224}
225
226pub 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
234pub 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
260pub 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 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}