Skip to main content

scythe_codegen/
resolve.rs

1use scythe_backend::manifest::BackendManifest;
2use scythe_backend::naming::{NamingConfig, field_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
11/// Resolve analyzed columns into resolved columns using a backend manifest.
12///
13/// When `overrides` is non-empty, each column is checked against the override
14/// list before the normal type-resolution path. The first matching override
15/// replaces the column's neutral type.
16pub fn resolve_columns(
17    columns: &[AnalyzedColumn],
18    manifest: &BackendManifest,
19    overrides: &[TypeOverride],
20    source_table: &str,
21) -> Result<Vec<ResolvedColumn>, ScytheError> {
22    let resolved: Vec<ResolvedColumn> = columns
23        .iter()
24        .map(|col| {
25            let column_match = if source_table.is_empty() {
26                String::new()
27            } else {
28                format!("{}.{}", source_table, col.name)
29            };
30            let effective_neutral_type =
31                find_override(overrides, &column_match, &col.neutral_type).unwrap_or(&col.neutral_type);
32
33            let (full_type, lang_type) = resolve_type_pair(effective_neutral_type, manifest, col.nullable)
34                .map(|(f, l)| (f.into_owned(), l.into_owned()))
35                .map_err(|e| {
36                    ScytheError::new(
37                        ErrorCode::InternalError,
38                        format!("type resolution failed for column '{}': {}", col.name, e),
39                    )
40                })?;
41            Ok(ResolvedColumn {
42                name: col.name.clone(),
43                field_name: field_name(&col.name, &manifest.naming).into_owned(),
44                lang_type,
45                full_type,
46                neutral_type: effective_neutral_type.to_string(),
47                nullable: col.nullable,
48                join_group: col.join_group.clone(),
49                nullable_before_join: col.nullable_before_join,
50                sql_type: col.sql_type.clone(),
51            })
52        })
53        .collect::<Result<_, ScytheError>>()?;
54
55    check_field_name_collisions(
56        resolved.iter().map(|c| (c.name.as_str(), c.field_name.as_str())),
57        "columns",
58        &manifest.naming,
59    )?;
60
61    Ok(resolved)
62}
63
64/// Resolve analyzed params into resolved params using a backend manifest.
65///
66/// When `overrides` is non-empty, each param is checked against the override
67/// list before the normal type-resolution path.
68pub fn resolve_params(
69    params: &[AnalyzedParam],
70    manifest: &BackendManifest,
71    overrides: &[TypeOverride],
72    source_table: &str,
73) -> Result<Vec<ResolvedParam>, ScytheError> {
74    let resolved: Vec<ResolvedParam> = params
75        .iter()
76        .map(|param| {
77            let column_match = if source_table.is_empty() {
78                String::new()
79            } else {
80                format!("{}.{}", source_table, param.name)
81            };
82            let effective_neutral_type =
83                find_override(overrides, &column_match, &param.neutral_type).unwrap_or(&param.neutral_type);
84
85            let (full_type, lang_type) = resolve_type_pair(effective_neutral_type, manifest, param.nullable)
86                .map(|(f, l)| (f.into_owned(), l.into_owned()))
87                .map_err(|e| {
88                    ScytheError::new(
89                        ErrorCode::InternalError,
90                        format!("type resolution failed for param '{}': {}", param.name, e),
91                    )
92                })?;
93            let borrowed_type = param_type_to_borrowed(&full_type);
94            Ok(ResolvedParam {
95                name: param.name.clone(),
96                field_name: field_name(&param.name, &manifest.naming).into_owned(),
97                lang_type,
98                full_type,
99                borrowed_type,
100                neutral_type: effective_neutral_type.to_string(),
101                nullable: param.nullable,
102            })
103        })
104        .collect::<Result<_, ScytheError>>()?;
105
106    check_field_name_collisions(
107        resolved.iter().map(|p| (p.name.as_str(), p.field_name.as_str())),
108        "params",
109        &manifest.naming,
110    )?;
111
112    Ok(resolved)
113}
114
115/// Reject two SQL identifiers in the same column list or param list that
116/// collapse onto the same generated field name.
117///
118/// Not just a `camelCase` problem: `field_name` always runs `to_snake_case`
119/// as its base transform (see `naming::apply_case`), and `to_snake_case` is
120/// not the identity function for mixed- or upper-case identifiers, so
121/// quoted SQL like `SELECT "USER_ID", user_id FROM t` folds both onto
122/// `user_id` even under the default `snake_case`. The analyzer's
123/// `duplicate_alias` check cannot catch this in any case -- it runs on the
124/// raw SQL names, before this conversion. So this runs unconditionally,
125/// regardless of `field_case`: it is O(n^2) over one query's column or
126/// param list, typically well under thirty items, so the cost is
127/// negligible next to the type resolution this function already does.
128fn check_field_name_collisions<'a>(
129    items: impl Iterator<Item = (&'a str, &'a str)>,
130    kind: &str,
131    naming: &NamingConfig,
132) -> Result<(), ScytheError> {
133    let items: Vec<(&str, &str)> = items.collect();
134    for i in 0..items.len() {
135        for j in (i + 1)..items.len() {
136            let (sql_a, field_a) = items[i];
137            let (sql_b, field_b) = items[j];
138            if field_a == field_b {
139                let alternative = if naming.field_case == "snake_case" {
140                    String::new()
141                } else {
142                    ", or set field_case = \"snake_case\"".to_string()
143                };
144                return Err(ScytheError::new(
145                    ErrorCode::DuplicateAlias,
146                    format!(
147                        "{kind} '{sql_a}' and '{sql_b}' both resolve to field name '{field_a}' under \
148                         field_case = \"{}\" -- alias one of them in SQL{alternative}",
149                        naming.field_case
150                    ),
151                ));
152            }
153        }
154    }
155    Ok(())
156}
157
158/// Convert a resolved Rust type to its borrowed form for function parameters.
159/// Copy types (primitives) stay as-is; String becomes &str; other non-Copy types get a & prefix.
160pub fn param_type_to_borrowed(rust_type: &str) -> String {
161    let copy_types = ["bool", "i16", "i32", "i64", "f32", "f64", "u64"];
162    if copy_types.contains(&rust_type) {
163        return rust_type.to_string();
164    }
165    if rust_type == "String" {
166        return "&str".to_string();
167    }
168    if let Some(inner) = rust_type.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) {
169        let borrowed_inner = param_type_to_borrowed(inner);
170        return format!("Option<{}>", borrowed_inner);
171    }
172    if let Some(inner) = rust_type.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
173        return format!("&[{}]", inner);
174    }
175    format!("&{}", rust_type)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_param_type_to_borrowed_string() {
184        assert_eq!(param_type_to_borrowed("String"), "&str");
185    }
186
187    #[test]
188    fn test_param_type_to_borrowed_vec() {
189        assert_eq!(param_type_to_borrowed("Vec<i32>"), "&[i32]");
190        assert_eq!(param_type_to_borrowed("Vec<String>"), "&[String]");
191    }
192
193    #[test]
194    fn test_param_type_to_borrowed_passthrough() {
195        assert_eq!(param_type_to_borrowed("i32"), "i32");
196        assert_eq!(param_type_to_borrowed("i64"), "i64");
197        assert_eq!(param_type_to_borrowed("bool"), "bool");
198        assert_eq!(param_type_to_borrowed("f64"), "f64");
199    }
200
201    #[test]
202    fn test_param_type_to_borrowed_option_string() {
203        assert_eq!(param_type_to_borrowed("Option<String>"), "Option<&str>");
204    }
205
206    #[test]
207    fn test_param_type_to_borrowed_option_copy() {
208        assert_eq!(param_type_to_borrowed("Option<i32>"), "Option<i32>");
209    }
210
211    #[test]
212    fn test_param_type_to_borrowed_other() {
213        assert_eq!(param_type_to_borrowed("Uuid"), "&Uuid");
214        assert_eq!(param_type_to_borrowed("NaiveDateTime"), "&NaiveDateTime");
215    }
216
217    fn test_manifest(field_case: &str) -> BackendManifest {
218        use ahash::AHashMap;
219        use scythe_backend::manifest::{BackendMeta, TypeMappings};
220
221        let mut scalars = AHashMap::new();
222        scalars.insert("string".to_string(), "String".to_string());
223        scalars.insert("int32".to_string(), "i32".to_string());
224
225        BackendManifest {
226            backend: BackendMeta {
227                name: "test".to_string(),
228                language: "rust".to_string(),
229                file_extension: "rs".to_string(),
230                engine: "postgresql".to_string(),
231                description: None,
232            },
233            types: TypeMappings {
234                scalars,
235                containers: AHashMap::new(),
236            },
237            naming: NamingConfig {
238                struct_case: "PascalCase".to_string(),
239                fn_case: "snake_case".to_string(),
240                enum_variant_case: "PascalCase".to_string(),
241                row_suffix: "Row".to_string(),
242                field_case: field_case.to_string(),
243            },
244            imports: None,
245        }
246    }
247
248    fn test_column(name: &str) -> AnalyzedColumn {
249        AnalyzedColumn {
250            name: name.to_string(),
251            neutral_type: "string".to_string(),
252            ..Default::default()
253        }
254    }
255
256    fn test_param(name: &str, position: i64) -> AnalyzedParam {
257        AnalyzedParam {
258            name: name.to_string(),
259            neutral_type: "string".to_string(),
260            nullable: false,
261            position,
262        }
263    }
264
265    #[test]
266    fn test_resolve_columns_defaults_to_snake_case_field_names() {
267        let manifest = test_manifest("snake_case");
268        let resolved = resolve_columns(&[test_column("UserId")], &manifest, &[], "").unwrap();
269        assert_eq!(resolved[0].field_name, "user_id");
270    }
271
272    #[test]
273    fn test_resolve_columns_honors_camel_case_field_case() {
274        let manifest = test_manifest("camelCase");
275        let resolved = resolve_columns(&[test_column("user_id")], &manifest, &[], "").unwrap();
276        assert_eq!(resolved[0].field_name, "userId");
277    }
278
279    #[test]
280    fn test_resolve_params_honors_camel_case_field_case() {
281        let manifest = test_manifest("camelCase");
282        let resolved = resolve_params(&[test_param("user_id", 1)], &manifest, &[], "").unwrap();
283        assert_eq!(resolved[0].field_name, "userId");
284    }
285
286    /// This must fail before the fix: `field_name` always runs
287    /// `to_snake_case` as its base transform, and `to_snake_case` is not the
288    /// identity function for mixed-/upper-case identifiers --
289    /// `to_snake_case("USER_ID") == "user_id"`. Quoted SQL like
290    /// `SELECT "USER_ID", user_id FROM t` is legal and passes the
291    /// analyzer's case-sensitive `duplicate_alias` check, so this collision
292    /// must be caught here, even under the default `field_case`. Skipping
293    /// the check when `field_case == "snake_case"` (the previous behavior)
294    /// let it through and produced two struct fields both named `user_id`.
295    #[test]
296    fn test_resolve_columns_rejects_collision_under_snake_case() {
297        let manifest = test_manifest("snake_case");
298        let err = resolve_columns(&[test_column("user_id"), test_column("USER_ID")], &manifest, &[], "")
299            .expect_err("user_id and USER_ID must collide under snake_case too");
300        let message = err.to_string();
301        assert!(message.contains("user_id"), "{message}");
302        assert!(message.contains("USER_ID"), "{message}");
303        assert!(
304            !message.contains("or set field_case"),
305            "already snake_case -- switching field_case cannot fix this: {message}"
306        );
307    }
308
309    #[test]
310    fn test_resolve_columns_rejects_collision_under_camel_case() {
311        let manifest = test_manifest("camelCase");
312        let err = resolve_columns(&[test_column("user_id"), test_column("userId")], &manifest, &[], "")
313            .expect_err("user_id and userId must collide under camelCase");
314        let message = err.to_string();
315        assert!(message.contains("user_id"), "{message}");
316        assert!(message.contains("userId"), "{message}");
317        assert!(message.contains("field_case"), "{message}");
318    }
319
320    #[test]
321    fn test_resolve_columns_rejects_collision_across_four_spellings() {
322        let manifest = test_manifest("camelCase");
323        for (a, b) in [
324            ("user_id", "USER_ID"),
325            ("user_id", "UserId"),
326            ("USER_ID", "userId"),
327            ("UserId", "userId"),
328        ] {
329            resolve_columns(&[test_column(a), test_column(b)], &manifest, &[], "")
330                .expect_err(&format!("{a} and {b} must collide under camelCase"));
331        }
332    }
333
334    #[test]
335    fn test_resolve_columns_rejects_double_underscore_collision() {
336        let manifest = test_manifest("camelCase");
337        resolve_columns(&[test_column("a_b"), test_column("a__b")], &manifest, &[], "")
338            .expect_err("a_b and a__b must both collapse to aB under camelCase");
339    }
340
341    #[test]
342    fn test_resolve_columns_rejects_leading_underscore_collision() {
343        let manifest = test_manifest("camelCase");
344        resolve_columns(&[test_column("id"), test_column("_id")], &manifest, &[], "")
345            .expect_err("id and _id must both collapse to id under camelCase");
346    }
347
348    #[test]
349    fn test_resolve_params_rejects_collision_under_camel_case() {
350        let manifest = test_manifest("camelCase");
351        let err = resolve_params(&[test_param("user_id", 1), test_param("userId", 2)], &manifest, &[], "")
352            .expect_err("user_id and userId must collide under camelCase");
353        assert!(err.to_string().contains("params"), "{err}");
354    }
355
356    #[test]
357    fn test_resolve_columns_no_collision_when_names_differ() {
358        let manifest = test_manifest("camelCase");
359        let resolved = resolve_columns(&[test_column("user_id"), test_column("order_id")], &manifest, &[], "").unwrap();
360        assert_eq!(resolved[0].field_name, "userId");
361        assert_eq!(resolved[1].field_name, "orderId");
362    }
363}