Skip to main content

scythe_codegen/
resolve.rs

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
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.
16///
17/// `column_match` is built per column from [`AnalyzedColumn::source_relation`] -- the
18/// analyzer's real owning-table for that column -- rather than from a single query-level
19/// table name. A query-level name only ever exists for a single-table `SELECT *`
20/// (`detect_select_star_source`), so building `column_match` from it made a `column =
21/// "table.col"` override a no-op for every other projection (#189).
22pub 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
68/// Resolve analyzed params into resolved params using a backend manifest.
69///
70/// When `overrides` is non-empty, each param is checked against the override
71/// list before the normal type-resolution path.
72///
73/// `column_match` prefers [`AnalyzedParam::source_relation`] -- the real relation a direct
74/// `WHERE table.column = $N` comparison bound this parameter against -- over the query-level
75/// `source_table` fallback. `source_table` only ever comes from `detect_select_star_source`
76/// (a single-table `SELECT *`), so before `AnalyzedParam` carried its own relation, a
77/// qualified `column = "table.col"` override on a parameter was a silent no-op for any other
78/// projection -- the parameter half of #189, left tracked when the column half was fixed in
79/// 99227e8e. `source_table` stays as the fallback (rather than being removed) so a parameter
80/// with no traceable owning column -- `IN` list, `LIKE`, a literal comparison -- keeps
81/// resolving the same way it did before this field existed.
82pub 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, &param.neutral_type).unwrap_or(&param.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(&param.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
129/// Every `"table.column"` reference actually present across a set of analyzed params, keyed
130/// on [`AnalyzedParam::source_relation`] -- the parameter counterpart of
131/// [`crate::overrides::column_references`], which does the same job for
132/// [`AnalyzedColumn::source_relation`]. A param with no single owning relation (`IN` list,
133/// `LIKE` pattern, literal comparison: `source_relation: None`) contributes nothing, which is
134/// exactly what a qualified `column` override can never legitimately target either.
135///
136/// Wired into the CLI's unmatched-override preflight
137/// ([`crate::overrides::unmatched_column_overrides`]) via `scythe-cli`'s
138/// `check_type_overrides_resolve`, which chains this alongside `column_references` into the
139/// `known` set it builds -- reusing the existing diagnostic rather than adding a second one
140/// (#189's remainder).
141pub 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
152/// Reject two SQL identifiers in the same column list or param list that
153/// collapse onto the same generated field name.
154///
155/// Not just a `camelCase` problem: `field_name` always runs `to_snake_case`
156/// as its base transform (see `naming::apply_case`), and `to_snake_case` is
157/// not the identity function for mixed- or upper-case identifiers, so
158/// quoted SQL like `SELECT "USER_ID", user_id FROM t` folds both onto
159/// `user_id` even under the default `snake_case`. The analyzer's
160/// `duplicate_alias` check cannot catch this in any case -- it runs on the
161/// raw SQL names, before this conversion. So this runs unconditionally,
162/// regardless of `field_case`: it is O(n^2) over one query's column or
163/// param list, typically well under thirty items, so the cost is
164/// negligible next to the type resolution this function already does.
165///
166/// `pub(crate)` rather than private: `lib.rs`'s `:grouped` path reuses this
167/// same check against the parent column list *plus* the synthesized
168/// `children` field a grouped backend injects, so that collision is caught
169/// by the same single rule instead of a second, independent one (#188).
170pub(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
200/// Reject two generated *type* names -- an enum, a query's own row/model
201/// struct -- that collapse onto the same identifier within one generated
202/// file.
203///
204/// The same `ErrorCode::DuplicateAlias` mechanism
205/// [`check_field_name_collisions`] uses, generalized for names cased under
206/// `struct_case` rather than `field_case`: an enum whose SQL name
207/// case-converts to the same spelling as another enum, or as the query's own
208/// row/model type, is two type declarations sharing one name -- `E0428` in
209/// Rust, a redeclaration in every other target (#136). Deliberately not
210/// built by widening `check_field_name_collisions` itself: that function's
211/// error message offers "switch `field_case`" as the fix, which does not
212/// apply here -- every manifest's `struct_case` is PascalCase, so there is
213/// no alternative case to point at instead.
214pub(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
237/// Reject two SQL values of the *same* enum whose rendered variant names
238/// collapse onto the same identifier.
239///
240/// The variant counterpart of [`check_type_name_collisions`], scoped to one
241/// enum's own value list rather than compared across enums: `enum_name` never
242/// participates in the comparison, only labels the error, because two
243/// different enums are free to both declare a variant called `Active` --
244/// they render into two different `enum` bodies, so nothing collides.
245/// `enum_variant_name` sanitizes non-identifier characters before casing
246/// (`naming::sanitize_for_identifier`), so two SQL labels that are legal and
247/// distinct in SQL -- `'gpt-3.5-turbo'` and `'gpt_3_5_turbo'` -- can both
248/// resolve to `Gpt35Turbo` and silently overwrite one another in the
249/// rendered `enum` (Rust `E0428`, and the equivalent redeclaration in every
250/// other target language) (#136). `generate_enum_defs_via_backend` is the
251/// only call site: it runs this once per enum, before handing the same
252/// `EnumInfo` to any backend's `generate_enum_def`.
253pub(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
276/// Convert a resolved Rust type to its borrowed form for function parameters.
277/// Copy types (primitives) stay as-is; String becomes &str; other non-Copy types get a & prefix.
278pub 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    /// A column with a real owning relation, the way the analyzer populates it for both an
379    /// explicit select list and a `SELECT *` expansion alike (#189) -- unlike `test_column`
380    /// above, whose `source_relation` defaults to `None`, the shape a computed expression or
381    /// a literal gets.
382    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    /// A param with a real owning relation, the way the analyzer populates it for a direct
402    /// `col = $N` comparison (`try_bind_param_from_comparison`) -- unlike `test_param` above,
403    /// whose `source_relation` defaults to `None`, the shape an `IN` list, a `LIKE` pattern, or
404    /// a literal comparison gets (#189's remainder).
405    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    /// This must fail before the fix: `field_name` always runs
437    /// `to_snake_case` as its base transform, and `to_snake_case` is not the
438    /// identity function for mixed-/upper-case identifiers --
439    /// `to_snake_case("USER_ID") == "user_id"`. Quoted SQL like
440    /// `SELECT "USER_ID", user_id FROM t` is legal and passes the
441    /// analyzer's case-sensitive `duplicate_alias` check, so this collision
442    /// must be caught here, even under the default `field_case`. Skipping
443    /// the check when `field_case == "snake_case"` (the previous behavior)
444    /// let it through and produced two struct fields both named `user_id`.
445    #[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    /// Must fail before the fix: `column_match` used to be built once, for the whole query,
507    /// from `analyzed.source_table` -- which `detect_select_star_source` only ever
508    /// populates for a single-table `SELECT *`. For `SELECT id FROM users` (an explicit
509    /// select list), that query-level name was empty, so `column_match` was `""` for every
510    /// column and `"users.id"` could never match it -- the override silently never fired
511    /// (#189). Rebuilding `column_match` per column from `AnalyzedColumn::source_relation`
512    /// fixes it because the analyzer populates that field the same way regardless of
513    /// whether the column came from a wildcard or an explicit list.
514    #[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    /// The scenario #189 says already worked and must keep working: a `SELECT *` expansion
528    /// resolves every column to the same single table, and the analyzer stamps that same
529    /// `source_relation` on each one, so a qualified override still applies post-fix.
530    #[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    /// Must fail before the fix: `TypeOverride::matches` returned `false` as soon as
554    /// `column` was set and didn't match, without ever checking `db_type` on that same
555    /// entry -- a combined `column` + `db_type` override went completely silent for every
556    /// column but the one literally named in `column` (#189), instead of degrading to the
557    /// type-level rule.
558    #[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        // `orders.total` doesn't match `column`, but its neutral_type ("string") matches
567        // `db_type` -- the entry must still fire via that fallback.
568        let resolved = resolve_columns(&[test_column_with_relation("total", "orders")], &manifest, &overrides).unwrap();
569        assert_eq!(resolved[0].neutral_type, "int32");
570    }
571
572    /// Must fail before the fix: `resolve_params`'s `column_match` was built once, for the
573    /// whole query, from a `source_table: &str` argument -- which `detect_select_star_source`
574    /// only ever populates for a single-table `SELECT *`. For `SELECT id FROM users WHERE
575    /// email = $1` (an explicit select list), `source_table` was empty, so `column_match` was
576    /// `""` for every param and `"users.email"` could never match it -- the override silently
577    /// never fired (#189's remainder). Building `column_match` from
578    /// `AnalyzedParam::source_relation` first fixes it because the analyzer now populates that
579    /// field from the column a direct comparison bound the parameter against, regardless of
580    /// whether the query is `SELECT *`.
581    #[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        // ~keep Assert the resolved language type too: the override has to survive
598        // `resolve_type`, not merely relabel the neutral type on the way past it.
599        assert_eq!(resolved[0].lang_type, "i32");
600    }
601
602    /// A parameter with no single owning column -- an `IN` list, a `LIKE` pattern, a literal
603    /// comparison -- keeps `source_relation: None`. A qualified override naming a table and
604    /// column therefore cannot match it via `resolve_params` alone (#189's remainder says this
605    /// case must be *reported*, not silently accepted as a match; that diagnostic is built
606    /// from [`crate::overrides::column_references`]/`unmatched_column_overrides` plus this
607    /// param's own qualified reference -- see the module doc on why `resolve_params` itself
608    /// only ever silently falls through here rather than erroring).
609    #[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    /// Unlike `check_field_name_collisions`, there is no `field_case`
666    /// alternative to suggest -- `struct_case` has no per-manifest escape
667    /// hatch, so the message must not claim one.
668    #[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    /// Must fail before the fix existed (there was no `AnalyzedParam::source_relation` to
704    /// build this set from at all): a `column` override naming a table/column that no
705    /// parameter's `source_relation` reaches is unmatched, the same way an unmatched column
706    /// override is caught by `crate::overrides::unmatched_column_overrides` today. Composing
707    /// `param_references` with that existing function (rather than a parallel "unmatched
708    /// param override" check) is the extension #189's remainder asks for.
709    #[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    /// A param with no owning column contributes nothing to `param_references`, mirroring
732    /// `column_references`'s handling of a computed/literal column.
733    #[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}