Skip to main content

scythe_codegen/
overrides.rs

1use ahash::AHashSet;
2
3use scythe_core::analyzer::AnalyzedColumn;
4
5/// A type override that replaces the inferred neutral type for a column or SQL type.
6///
7/// Overrides are evaluated in order: the first match wins. A `column` match
8/// (e.g. `"users.metadata"`) takes priority over a `db_type` match when both
9/// fields are set on the same override entry, but a `column` that fails to
10/// match falls through to `db_type` on the same entry rather than making the
11/// whole entry a no-op (#189) -- the two predicates degrade independently,
12/// they don't gate each other.
13#[derive(Debug, Clone)]
14pub struct TypeOverride {
15    /// Fully-qualified column reference in `"table.column"` format.
16    pub column: Option<String>,
17    /// SQL type name (matched case-insensitively against the column's neutral type).
18    pub db_type: Option<String>,
19    /// Target neutral type to substitute (e.g. `"string"`, `"json"`).
20    pub neutral_type: Option<String>,
21}
22
23impl TypeOverride {
24    /// Check if this override matches a column.
25    ///
26    /// `column_match` is `"table_name.column_name"` (empty string if unknown).
27    /// `col_neutral_type` is the neutral type inferred by the analyzer.
28    pub fn matches(&self, column_match: &str, col_neutral_type: &str) -> bool {
29        if let Some(ref col) = self.column
30            && col == column_match
31        {
32            return true;
33        }
34        if let Some(ref dt) = self.db_type {
35            return dt.eq_ignore_ascii_case(col_neutral_type);
36        }
37        false
38    }
39}
40
41/// Every `"table.column"` reference actually present across a set of resolved columns,
42/// keyed on [`AnalyzedColumn::source_relation`] -- the analyzer's per-column owning
43/// relation (#189) -- rather than a query-level `source_table`. A column with no single
44/// owning relation (`source_relation: None`: a computed expression, a literal, a function
45/// result) contributes nothing, which is exactly what a qualified `column` override can
46/// never legitimately target either.
47pub fn column_references<'a>(columns: impl Iterator<Item = &'a AnalyzedColumn>) -> AHashSet<String> {
48    columns
49        .filter_map(|col| {
50            col.source_relation
51                .as_deref()
52                .map(|relation| format!("{relation}.{}", col.name))
53        })
54        .collect()
55}
56
57/// `column` overrides whose target names no `"table.column"` pair in `known_references`.
58///
59/// This is the other half of #189: fixing `column_match` to bind per-column stops the
60/// override from silently doing nothing, but a `column` that names a table or column that
61/// plain doesn't exist in this schema (a typo, a renamed table) would otherwise go quiet
62/// again the same way -- it just never gets a chance to fire. `db_type`-only entries are
63/// never reported here: they have no single column to miss.
64pub fn unmatched_column_overrides<'a>(
65    overrides: &'a [TypeOverride],
66    known_references: &AHashSet<String>,
67) -> Vec<&'a TypeOverride> {
68    overrides
69        .iter()
70        .filter(|o| matches!(&o.column, Some(c) if !known_references.contains(c)))
71        .collect()
72}
73
74/// Find the first override that matches a column and return its neutral type.
75///
76/// Returns `None` when no override matches — the caller should fall through to
77/// the default type-resolution path.
78pub fn find_override<'a>(overrides: &'a [TypeOverride], column_match: &str, col_neutral_type: &str) -> Option<&'a str> {
79    overrides.iter().find_map(|o| {
80        if o.matches(column_match, col_neutral_type) {
81            o.neutral_type.as_deref()
82        } else {
83            None
84        }
85    })
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_column_override_matches() {
94        let o = TypeOverride {
95            column: Some("users.metadata".to_string()),
96            db_type: None,
97            neutral_type: Some("json".to_string()),
98        };
99        assert!(o.matches("users.metadata", "jsonb"));
100        assert!(!o.matches("posts.metadata", "jsonb"));
101    }
102
103    #[test]
104    fn test_db_type_override_matches() {
105        let o = TypeOverride {
106            column: None,
107            db_type: Some("ltree".to_string()),
108            neutral_type: Some("string".to_string()),
109        };
110        assert!(o.matches("", "ltree"));
111        assert!(o.matches("any.col", "LTREE"));
112        assert!(!o.matches("any.col", "text"));
113    }
114
115    #[test]
116    fn test_column_takes_priority_over_db_type() {
117        let o = TypeOverride {
118            column: Some("users.name".to_string()),
119            db_type: Some("text".to_string()),
120            neutral_type: Some("custom".to_string()),
121        };
122        assert!(o.matches("users.name", "int32"));
123        assert!(!o.matches("other.name", "int32"));
124    }
125
126    /// Must fail before the fix: `matches` used to return `false` as soon as `column` was
127    /// set and didn't match, without ever checking `db_type` -- so a combined entry went
128    /// completely silent for every column except the one literally named in `column`,
129    /// instead of degrading to the type-level rule (#189).
130    #[test]
131    fn test_column_miss_falls_through_to_db_type() {
132        let o = TypeOverride {
133            column: Some("users.name".to_string()),
134            db_type: Some("text".to_string()),
135            neutral_type: Some("custom".to_string()),
136        };
137        assert!(o.matches("other.name", "text"));
138        assert!(o.matches("other.name", "TEXT"));
139    }
140
141    #[test]
142    fn test_find_override_first_match_wins() {
143        let overrides = vec![
144            TypeOverride {
145                column: Some("users.metadata".to_string()),
146                db_type: None,
147                neutral_type: Some("json".to_string()),
148            },
149            TypeOverride {
150                column: None,
151                db_type: Some("jsonb".to_string()),
152                neutral_type: Some("string".to_string()),
153            },
154        ];
155        assert_eq!(find_override(&overrides, "users.metadata", "jsonb"), Some("json"));
156        assert_eq!(find_override(&overrides, "posts.data", "jsonb"), Some("string"));
157        assert_eq!(find_override(&overrides, "posts.data", "text"), None);
158    }
159
160    #[test]
161    fn test_find_override_empty_list() {
162        assert_eq!(find_override(&[], "users.id", "int32"), None);
163    }
164
165    fn column(name: &str, relation: Option<&str>) -> AnalyzedColumn {
166        AnalyzedColumn {
167            name: name.to_string(),
168            neutral_type: "string".to_string(),
169            source_relation: relation.map(str::to_string),
170            ..Default::default()
171        }
172    }
173
174    /// A computed expression, literal, or function result -- `source_relation: None` --
175    /// contributes no `"table.column"` reference: it is exactly what a qualified `column`
176    /// override can never legitimately target either (#189).
177    #[test]
178    fn test_column_references_skips_columns_with_no_source_relation() {
179        let columns = [column("id", Some("users")), column("total", None)];
180        let refs = column_references(columns.iter());
181        assert_eq!(refs.len(), 1);
182        assert!(refs.contains("users.id"));
183    }
184
185    /// Must fail before the fix existed: a `column` override naming a table or column
186    /// present nowhere in `known_references` (a typo) must be reported; a matching one and
187    /// a `db_type`-only one (no single column to miss) must not be.
188    #[test]
189    fn test_unmatched_column_overrides_reports_only_the_typo() {
190        let known = column_references([column("id", Some("users"))].iter());
191        let overrides = vec![
192            TypeOverride {
193                column: Some("users.id".to_string()),
194                db_type: None,
195                neutral_type: Some("string".to_string()),
196            },
197            TypeOverride {
198                column: Some("users.emial".to_string()),
199                db_type: None,
200                neutral_type: Some("string".to_string()),
201            },
202            TypeOverride {
203                column: None,
204                db_type: Some("jsonb".to_string()),
205                neutral_type: Some("string".to_string()),
206            },
207        ];
208        let unmatched = unmatched_column_overrides(&overrides, &known);
209        assert_eq!(unmatched.len(), 1);
210        assert_eq!(unmatched[0].column.as_deref(), Some("users.emial"));
211    }
212}