1use scythe_backend::manifest::BackendManifest;
2use scythe_backend::naming::{NamingConfig, field_name, param_name};
3use scythe_backend::types::resolve_type_pair;
4
5use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam};
6use scythe_core::errors::{ErrorCode, ScytheError};
7
8use crate::backend_trait::{ResolvedColumn, ResolvedParam};
9use crate::overrides::{TypeOverride, find_override};
10
11pub fn resolve_columns(
23 columns: &[AnalyzedColumn],
24 manifest: &BackendManifest,
25 overrides: &[TypeOverride],
26) -> Result<Vec<ResolvedColumn>, ScytheError> {
27 let resolved: Vec<ResolvedColumn> = columns
28 .iter()
29 .map(|col| {
30 let column_match = match col.source_relation {
31 Some(ref relation) => format!("{relation}.{}", col.name),
32 None => String::new(),
33 };
34 let effective_neutral_type =
35 find_override(overrides, &column_match, &col.neutral_type).unwrap_or(&col.neutral_type);
36
37 let (full_type, lang_type) = resolve_type_pair(effective_neutral_type, manifest, col.nullable)
38 .map(|(f, l)| (f.into_owned(), l.into_owned()))
39 .map_err(|e| {
40 ScytheError::new(
41 ErrorCode::InternalError,
42 format!("type resolution failed for column '{}': {}", col.name, e),
43 )
44 })?;
45 Ok(ResolvedColumn {
46 name: col.name.clone(),
47 field_name: field_name(&col.name, &manifest.naming).into_owned(),
48 lang_type,
49 full_type,
50 neutral_type: effective_neutral_type.to_string(),
51 nullable: col.nullable,
52 join_group: col.join_group.clone(),
53 nullable_before_join: col.nullable_before_join,
54 sql_type: col.sql_type.clone(),
55 })
56 })
57 .collect::<Result<_, ScytheError>>()?;
58
59 check_field_name_collisions(
60 resolved.iter().map(|c| (c.name.as_str(), c.field_name.as_str())),
61 "columns",
62 &manifest.naming,
63 )?;
64
65 Ok(resolved)
66}
67
68pub fn resolve_params(
83 params: &[AnalyzedParam],
84 manifest: &BackendManifest,
85 overrides: &[TypeOverride],
86 source_table: &str,
87) -> Result<Vec<ResolvedParam>, ScytheError> {
88 let resolved: Vec<ResolvedParam> = params
89 .iter()
90 .map(|param| {
91 let column_match = match param.source_relation {
92 Some(ref relation) => format!("{relation}.{}", param.name),
93 None if !source_table.is_empty() => format!("{}.{}", source_table, param.name),
94 None => String::new(),
95 };
96 let effective_neutral_type =
97 find_override(overrides, &column_match, ¶m.neutral_type).unwrap_or(¶m.neutral_type);
98
99 let (full_type, lang_type) = resolve_type_pair(effective_neutral_type, manifest, param.nullable)
100 .map(|(f, l)| (f.into_owned(), l.into_owned()))
101 .map_err(|e| {
102 ScytheError::new(
103 ErrorCode::InternalError,
104 format!("type resolution failed for param '{}': {}", param.name, e),
105 )
106 })?;
107 let borrowed_type = param_type_to_borrowed(&full_type);
108 Ok(ResolvedParam {
109 name: param.name.clone(),
110 field_name: param_name(¶m.name, &manifest.naming),
111 lang_type,
112 full_type,
113 borrowed_type,
114 neutral_type: effective_neutral_type.to_string(),
115 nullable: param.nullable,
116 })
117 })
118 .collect::<Result<_, ScytheError>>()?;
119
120 check_field_name_collisions(
121 resolved.iter().map(|p| (p.name.as_str(), p.field_name.as_str())),
122 "params",
123 &manifest.naming,
124 )?;
125
126 Ok(resolved)
127}
128
129pub fn param_references<'a>(params: impl Iterator<Item = &'a AnalyzedParam>) -> ahash::AHashSet<String> {
142 params
143 .filter_map(|param| {
144 param
145 .source_relation
146 .as_deref()
147 .map(|relation| format!("{relation}.{}", param.name))
148 })
149 .collect()
150}
151
152pub(crate) fn check_field_name_collisions<'a>(
171 items: impl Iterator<Item = (&'a str, &'a str)>,
172 kind: &str,
173 naming: &NamingConfig,
174) -> Result<(), ScytheError> {
175 let items: Vec<(&str, &str)> = items.collect();
176 for i in 0..items.len() {
177 for j in (i + 1)..items.len() {
178 let (sql_a, field_a) = items[i];
179 let (sql_b, field_b) = items[j];
180 if field_a == field_b {
181 let alternative = if naming.field_case == "snake_case" {
182 String::new()
183 } else {
184 ", or set field_case = \"snake_case\"".to_string()
185 };
186 return Err(ScytheError::new(
187 ErrorCode::DuplicateAlias,
188 format!(
189 "{kind} '{sql_a}' and '{sql_b}' both resolve to field name '{field_a}' under \
190 field_case = \"{}\" -- alias one of them in SQL{alternative}",
191 naming.field_case
192 ),
193 ));
194 }
195 }
196 }
197 Ok(())
198}
199
200pub(crate) fn check_type_name_collisions<'a>(
215 items: impl Iterator<Item = (&'a str, &'a str)>,
216 kind: &str,
217) -> Result<(), ScytheError> {
218 let items: Vec<(&str, &str)> = items.collect();
219 for i in 0..items.len() {
220 for j in (i + 1)..items.len() {
221 let (source_a, name_a) = items[i];
222 let (source_b, name_b) = items[j];
223 if name_a == name_b {
224 return Err(ScytheError::new(
225 ErrorCode::DuplicateAlias,
226 format!(
227 "{kind} '{source_a}' and '{source_b}' both resolve to generated name \
228 '{name_a}' -- rename one of them"
229 ),
230 ));
231 }
232 }
233 }
234 Ok(())
235}
236
237pub(crate) fn check_enum_variant_collisions<'a>(
254 items: impl Iterator<Item = (&'a str, &'a str)>,
255 enum_name: &str,
256) -> Result<(), ScytheError> {
257 let items: Vec<(&str, &str)> = items.collect();
258 for i in 0..items.len() {
259 for j in (i + 1)..items.len() {
260 let (source_a, name_a) = items[i];
261 let (source_b, name_b) = items[j];
262 if name_a == name_b {
263 return Err(ScytheError::new(
264 ErrorCode::DuplicateAlias,
265 format!(
266 "enum '{enum_name}' values '{source_a}' and '{source_b}' both resolve to variant \
267 '{name_a}' -- rename one of the SQL values"
268 ),
269 ));
270 }
271 }
272 }
273 Ok(())
274}
275
276pub fn param_type_to_borrowed(rust_type: &str) -> String {
279 let copy_types = ["bool", "i16", "i32", "i64", "f32", "f64", "u64"];
280 if copy_types.contains(&rust_type) {
281 return rust_type.to_string();
282 }
283 if rust_type == "String" {
284 return "&str".to_string();
285 }
286 if let Some(inner) = rust_type.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) {
287 let borrowed_inner = param_type_to_borrowed(inner);
288 return format!("Option<{}>", borrowed_inner);
289 }
290 if let Some(inner) = rust_type.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
291 return format!("&[{}]", inner);
292 }
293 format!("&{}", rust_type)
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_param_type_to_borrowed_string() {
302 assert_eq!(param_type_to_borrowed("String"), "&str");
303 }
304
305 #[test]
306 fn test_param_type_to_borrowed_vec() {
307 assert_eq!(param_type_to_borrowed("Vec<i32>"), "&[i32]");
308 assert_eq!(param_type_to_borrowed("Vec<String>"), "&[String]");
309 }
310
311 #[test]
312 fn test_param_type_to_borrowed_passthrough() {
313 assert_eq!(param_type_to_borrowed("i32"), "i32");
314 assert_eq!(param_type_to_borrowed("i64"), "i64");
315 assert_eq!(param_type_to_borrowed("bool"), "bool");
316 assert_eq!(param_type_to_borrowed("f64"), "f64");
317 }
318
319 #[test]
320 fn test_param_type_to_borrowed_option_string() {
321 assert_eq!(param_type_to_borrowed("Option<String>"), "Option<&str>");
322 }
323
324 #[test]
325 fn test_param_type_to_borrowed_option_copy() {
326 assert_eq!(param_type_to_borrowed("Option<i32>"), "Option<i32>");
327 }
328
329 #[test]
330 fn test_param_type_to_borrowed_other() {
331 assert_eq!(param_type_to_borrowed("Uuid"), "&Uuid");
332 assert_eq!(param_type_to_borrowed("NaiveDateTime"), "&NaiveDateTime");
333 }
334
335 fn test_manifest(field_case: &str) -> BackendManifest {
336 use ahash::AHashMap;
337 use scythe_backend::manifest::{BackendMeta, TypeMappings};
338
339 let mut scalars = AHashMap::new();
340 scalars.insert("string".to_string(), "String".to_string());
341 scalars.insert("int32".to_string(), "i32".to_string());
342
343 BackendManifest {
344 backend: BackendMeta {
345 name: "test".to_string(),
346 language: "rust".to_string(),
347 file_extension: "rs".to_string(),
348 engine: "postgresql".to_string(),
349 description: None,
350 },
351 types: TypeMappings {
352 scalars,
353 containers: AHashMap::new(),
354 docblock_containers: AHashMap::new(),
355 },
356 naming: NamingConfig {
357 struct_case: "PascalCase".to_string(),
358 fn_case: "snake_case".to_string(),
359 enum_variant_case: "PascalCase".to_string(),
360 row_suffix: "Row".to_string(),
361 field_case: field_case.to_string(),
362 reserved: Vec::new(),
363 reserved_bindings: Vec::new(),
364 sanitize_field_names: false,
365 },
366 imports: None,
367 }
368 }
369
370 fn test_column(name: &str) -> AnalyzedColumn {
371 AnalyzedColumn {
372 name: name.to_string(),
373 neutral_type: "string".to_string(),
374 ..Default::default()
375 }
376 }
377
378 fn test_column_with_relation(name: &str, relation: &str) -> AnalyzedColumn {
383 AnalyzedColumn {
384 name: name.to_string(),
385 neutral_type: "string".to_string(),
386 source_relation: Some(relation.to_string()),
387 ..Default::default()
388 }
389 }
390
391 fn test_param(name: &str, position: i64) -> AnalyzedParam {
392 AnalyzedParam {
393 name: name.to_string(),
394 neutral_type: "string".to_string(),
395 nullable: false,
396 position,
397 source_relation: None,
398 }
399 }
400
401 fn test_param_with_relation(name: &str, position: i64, relation: &str) -> AnalyzedParam {
406 AnalyzedParam {
407 name: name.to_string(),
408 neutral_type: "string".to_string(),
409 nullable: false,
410 position,
411 source_relation: Some(relation.to_string()),
412 }
413 }
414
415 #[test]
416 fn test_resolve_columns_defaults_to_snake_case_field_names() {
417 let manifest = test_manifest("snake_case");
418 let resolved = resolve_columns(&[test_column("UserId")], &manifest, &[]).unwrap();
419 assert_eq!(resolved[0].field_name, "user_id");
420 }
421
422 #[test]
423 fn test_resolve_columns_honors_camel_case_field_case() {
424 let manifest = test_manifest("camelCase");
425 let resolved = resolve_columns(&[test_column("user_id")], &manifest, &[]).unwrap();
426 assert_eq!(resolved[0].field_name, "userId");
427 }
428
429 #[test]
430 fn test_resolve_params_honors_camel_case_field_case() {
431 let manifest = test_manifest("camelCase");
432 let resolved = resolve_params(&[test_param("user_id", 1)], &manifest, &[], "").unwrap();
433 assert_eq!(resolved[0].field_name, "userId");
434 }
435
436 #[test]
446 fn test_resolve_columns_rejects_collision_under_snake_case() {
447 let manifest = test_manifest("snake_case");
448 let err = resolve_columns(&[test_column("user_id"), test_column("USER_ID")], &manifest, &[])
449 .expect_err("user_id and USER_ID must collide under snake_case too");
450 let message = err.to_string();
451 assert!(message.contains("user_id"), "{message}");
452 assert!(message.contains("USER_ID"), "{message}");
453 assert!(
454 !message.contains("or set field_case"),
455 "already snake_case -- switching field_case cannot fix this: {message}"
456 );
457 }
458
459 #[test]
460 fn test_resolve_columns_rejects_collision_under_camel_case() {
461 let manifest = test_manifest("camelCase");
462 let err = resolve_columns(&[test_column("user_id"), test_column("userId")], &manifest, &[])
463 .expect_err("user_id and userId must collide under camelCase");
464 let message = err.to_string();
465 assert!(message.contains("user_id"), "{message}");
466 assert!(message.contains("userId"), "{message}");
467 assert!(message.contains("field_case"), "{message}");
468 }
469
470 #[test]
471 fn test_resolve_columns_rejects_collision_across_four_spellings() {
472 let manifest = test_manifest("camelCase");
473 for (a, b) in [
474 ("user_id", "USER_ID"),
475 ("user_id", "UserId"),
476 ("USER_ID", "userId"),
477 ("UserId", "userId"),
478 ] {
479 resolve_columns(&[test_column(a), test_column(b)], &manifest, &[])
480 .expect_err(&format!("{a} and {b} must collide under camelCase"));
481 }
482 }
483
484 #[test]
485 fn test_resolve_columns_rejects_double_underscore_collision() {
486 let manifest = test_manifest("camelCase");
487 resolve_columns(&[test_column("a_b"), test_column("a__b")], &manifest, &[])
488 .expect_err("a_b and a__b must both collapse to aB under camelCase");
489 }
490
491 #[test]
492 fn test_resolve_columns_rejects_leading_underscore_collision() {
493 let manifest = test_manifest("camelCase");
494 resolve_columns(&[test_column("id"), test_column("_id")], &manifest, &[])
495 .expect_err("id and _id must both collapse to id under camelCase");
496 }
497
498 #[test]
499 fn test_resolve_params_rejects_collision_under_camel_case() {
500 let manifest = test_manifest("camelCase");
501 let err = resolve_params(&[test_param("user_id", 1), test_param("userId", 2)], &manifest, &[], "")
502 .expect_err("user_id and userId must collide under camelCase");
503 assert!(err.to_string().contains("params"), "{err}");
504 }
505
506 #[test]
515 fn test_resolve_columns_applies_qualified_override_on_explicit_select_list() {
516 let manifest = test_manifest("snake_case");
517 let overrides = vec![TypeOverride {
518 column: Some("users.id".to_string()),
519 db_type: None,
520 neutral_type: Some("int32".to_string()),
521 }];
522 let resolved = resolve_columns(&[test_column_with_relation("id", "users")], &manifest, &overrides).unwrap();
523 assert_eq!(resolved[0].neutral_type, "int32");
524 assert_eq!(resolved[0].lang_type, "i32");
525 }
526
527 #[test]
531 fn test_resolve_columns_qualified_override_still_applies_for_select_star() {
532 let manifest = test_manifest("snake_case");
533 let overrides = vec![TypeOverride {
534 column: Some("users.id".to_string()),
535 db_type: None,
536 neutral_type: Some("int32".to_string()),
537 }];
538 let columns = [
539 test_column_with_relation("id", "users"),
540 test_column_with_relation("name", "users"),
541 ];
542 let resolved = resolve_columns(&columns, &manifest, &overrides).unwrap();
543 assert_eq!(
544 resolved[0].neutral_type, "int32",
545 "id: the qualified override must apply"
546 );
547 assert_eq!(
548 resolved[1].neutral_type, "string",
549 "name: untouched by an override naming a different column"
550 );
551 }
552
553 #[test]
559 fn test_resolve_columns_combined_override_falls_through_to_db_type() {
560 let manifest = test_manifest("snake_case");
561 let overrides = vec![TypeOverride {
562 column: Some("users.name".to_string()),
563 db_type: Some("string".to_string()),
564 neutral_type: Some("int32".to_string()),
565 }];
566 let resolved = resolve_columns(&[test_column_with_relation("total", "orders")], &manifest, &overrides).unwrap();
569 assert_eq!(resolved[0].neutral_type, "int32");
570 }
571
572 #[test]
582 fn test_resolve_params_applies_qualified_override_on_explicit_select_list() {
583 let manifest = test_manifest("snake_case");
584 let overrides = vec![TypeOverride {
585 column: Some("users.email".to_string()),
586 db_type: None,
587 neutral_type: Some("int32".to_string()),
588 }];
589 let resolved = resolve_params(
590 &[test_param_with_relation("email", 1, "users")],
591 &manifest,
592 &overrides,
593 "",
594 )
595 .unwrap();
596 assert_eq!(resolved[0].neutral_type, "int32");
597 assert_eq!(resolved[0].lang_type, "i32");
600 }
601
602 #[test]
610 fn test_resolve_params_qualified_override_does_not_match_param_with_no_relation() {
611 let manifest = test_manifest("snake_case");
612 let overrides = vec![TypeOverride {
613 column: Some("users.email".to_string()),
614 db_type: None,
615 neutral_type: Some("int32".to_string()),
616 }];
617 let resolved = resolve_params(&[test_param("email", 1)], &manifest, &overrides, "").unwrap();
618 assert_eq!(
619 resolved[0].neutral_type, "string",
620 "no source_relation means the qualified override cannot legitimately target this param"
621 );
622 }
623
624 #[test]
625 fn test_resolve_columns_no_collision_when_names_differ() {
626 let manifest = test_manifest("camelCase");
627 let resolved = resolve_columns(&[test_column("user_id"), test_column("order_id")], &manifest, &[]).unwrap();
628 assert_eq!(resolved[0].field_name, "userId");
629 assert_eq!(resolved[1].field_name, "orderId");
630 }
631
632 #[test]
633 fn test_check_type_name_collisions_rejects_two_enums() {
634 let err = check_type_name_collisions(
635 [("order-status", "OrderStatus"), ("order_status", "OrderStatus")].into_iter(),
636 "enums",
637 )
638 .expect_err("order-status and order_status must collide as OrderStatus");
639 assert_eq!(err.code, ErrorCode::DuplicateAlias);
640 let message = err.to_string();
641 assert!(message.contains("order-status"), "{message}");
642 assert!(message.contains("order_status"), "{message}");
643 assert!(message.contains("OrderStatus"), "{message}");
644 }
645
646 #[test]
647 fn test_check_type_name_collisions_rejects_enum_vs_query_type() {
648 let err = check_type_name_collisions(
649 [("user_status", "GetUserRow"), ("<query row/model type>", "GetUserRow")].into_iter(),
650 "enum and query type names",
651 )
652 .expect_err("an enum type name colliding with the query's own row type must be rejected");
653 assert_eq!(err.code, ErrorCode::DuplicateAlias);
654 }
655
656 #[test]
657 fn test_check_type_name_collisions_passes_when_distinct() {
658 check_type_name_collisions(
659 [("user_status", "UserStatus"), ("order_status", "OrderStatus")].into_iter(),
660 "enums",
661 )
662 .expect("distinct generated names must not collide");
663 }
664
665 #[test]
669 fn test_check_type_name_collisions_message_has_no_field_case_suggestion() {
670 let err = check_type_name_collisions([("a", "X"), ("b", "X")].into_iter(), "enums").unwrap_err();
671 assert!(!err.to_string().contains("field_case"), "{err}");
672 }
673
674 #[test]
675 fn test_check_enum_variant_collisions_rejects_two_values() {
676 let err = check_enum_variant_collisions(
677 [("gpt-3.5-turbo", "Gpt35Turbo"), ("gpt_3_5_turbo", "Gpt35Turbo")].into_iter(),
678 "model",
679 )
680 .expect_err("gpt-3.5-turbo and gpt_3_5_turbo must collide as Gpt35Turbo");
681 assert_eq!(err.code, ErrorCode::DuplicateAlias);
682 let message = err.to_string();
683 assert!(message.contains("model"), "{message}");
684 assert!(message.contains("gpt-3.5-turbo"), "{message}");
685 assert!(message.contains("gpt_3_5_turbo"), "{message}");
686 assert!(message.contains("Gpt35Turbo"), "{message}");
687 }
688
689 #[test]
690 fn test_check_enum_variant_collisions_passes_when_distinct() {
691 check_enum_variant_collisions([("active", "Active"), ("inactive", "Inactive")].into_iter(), "status")
692 .expect("distinct variant names must not collide");
693 }
694
695 #[test]
696 fn test_param_references_collects_qualified_names() {
697 let params = [test_param_with_relation("email", 1, "users"), test_param("total", 2)];
698 let refs = param_references(params.iter());
699 assert_eq!(refs.len(), 1);
700 assert!(refs.contains("users.email"));
701 }
702
703 #[test]
710 fn test_unmatched_column_overrides_reports_qualified_param_override_that_matches_nothing() {
711 use crate::overrides::unmatched_column_overrides;
712
713 let known = param_references([test_param_with_relation("email", 1, "users")].iter());
714 let overrides = vec![
715 TypeOverride {
716 column: Some("users.email".to_string()),
717 db_type: None,
718 neutral_type: Some("json".to_string()),
719 },
720 TypeOverride {
721 column: Some("users.emial".to_string()),
722 db_type: None,
723 neutral_type: Some("json".to_string()),
724 },
725 ];
726 let unmatched = unmatched_column_overrides(&overrides, &known);
727 assert_eq!(unmatched.len(), 1);
728 assert_eq!(unmatched[0].column.as_deref(), Some("users.emial"));
729 }
730
731 #[test]
734 fn test_param_references_skips_params_with_no_source_relation() {
735 let params = [test_param("total", 1)];
736 let refs = param_references(params.iter());
737 assert!(refs.is_empty());
738 }
739}