Skip to main content

sql_insight/
casing.rs

1//! Per-dialect identifier case-folding policy.
2//!
3//! SQL engines disagree on how identifiers compare for equality:
4//! whether an unquoted name folds to upper- or lower-case, whether a
5//! quoted name is then case-sensitive, and whether quoting matters at
6//! all. The binder matches identifiers (table qualifiers, column names,
7//! aliases) by their folded key, so it needs the active dialect's rule
8//! to decide e.g. whether `Users` and `users` are the same table.
9//!
10//! This folds the (well-surveyed) cross-dialect matrix down to a
11//! [`CaseRule`] per identifier *class*. The six syntactic positions
12//! (catalog / schema / table / table-alias / column / column-alias)
13//! collapse to three classes, because every dialect models
14//! catalog / schema / table alike, and column / column-alias alike:
15//!
16//! - [`IdentifierCasing::table`] — catalog / schema / table names (the
17//!   qualified name path of a stored table or view).
18//! - [`IdentifierCasing::table_alias`] — table aliases and the
19//!   synthetic names of CTEs / derived tables / table functions. Its
20//!   own class because BigQuery folds aliases case-insensitively while
21//!   keeping tables case-sensitive, and MySQL ties aliases to the
22//!   filesystem-dependent table rule — the two diverge, so neither
23//!   `table` nor `column` can stand in for it.
24//! - [`IdentifierCasing::column`] — column names and column aliases.
25//!
26//! Folding affects **matching only** — it never rewrites the surfaced
27//! [`TableReference`](crate::reference::TableReference) /
28//! [`ColumnReference`](crate::reference::ColumnReference). (Catalog
29//! canonicalization *does* rewrite a matched identity to its registered
30//! path, but that is a separate mechanism, not folding.)
31
32use sqlparser::ast::Ident;
33use sqlparser::dialect::{
34    AnsiDialect, BigQueryDialect, ClickHouseDialect, DatabricksDialect, Dialect, DuckDbDialect,
35    HiveDialect, MsSqlDialect, MySqlDialect, OracleDialect, PostgreSqlDialect, RedshiftSqlDialect,
36    SQLiteDialect, SnowflakeDialect,
37};
38
39/// How one identifier class folds before an equality comparison — the
40/// per-class element of an [`IdentifierCasing`].
41///
42/// The four cases the cross-dialect matrix reduces to once
43/// instance-specific models (filesystem-dependent, collation-dependent)
44/// are resolved to a concrete choice. Which dialect gets which rule lives
45/// in [`IdentifierCasing::for_dialect`]; this enum describes only the
46/// folding each rule performs.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum CaseRule {
49    /// Unquoted → upper-case; quoted → preserved (exact).
50    Upper,
51    /// Unquoted → lower-case; quoted → preserved (exact).
52    Lower,
53    /// Quoting ignored; comparison case-insensitive.
54    Insensitive,
55    /// Quoting ignored; comparison case-sensitive (exact).
56    ///
57    /// Used both by definitively case-sensitive engines and as the safe
58    /// fallback for filesystem-dependent real *table names*, where
59    /// over-matching (a false merge of two distinct stored tables) is a
60    /// data-correctness risk. Statement-local aliases don't take this
61    /// fallback — they default lenient (see
62    /// [`IdentifierCasing::table_alias`]).
63    Sensitive,
64}
65
66impl CaseRule {
67    /// Normalize `ident` to its comparison key under this fold.
68    pub(crate) fn normalize(self, ident: &Ident) -> String {
69        match self {
70            CaseRule::Upper if ident.quote_style.is_none() => ident.value.to_ascii_uppercase(),
71            CaseRule::Lower if ident.quote_style.is_none() => ident.value.to_ascii_lowercase(),
72            // Quoted under Upper/Lower → preserved; Sensitive → always
73            // preserved regardless of quoting.
74            CaseRule::Upper | CaseRule::Lower | CaseRule::Sensitive => ident.value.clone(),
75            // Quoting ignored; fold case away.
76            CaseRule::Insensitive => ident.value.to_ascii_lowercase(),
77        }
78    }
79}
80
81/// The identifier-casing policy for an analysis, split by identifier
82/// class. Build one with [`IdentifierCasing::for_dialect`] (the dialect's
83/// default), [`IdentifierCasing::uniform`] (one rule for every class), or
84/// the field literal, and pass it via `ExtractorOptions::with_casing` to a
85/// `*_with_options` extractor to override the dialect default — e.g. to
86/// model a deployment-specific collation.
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub struct IdentifierCasing {
89    /// catalog / schema / table names.
90    pub table: CaseRule,
91    /// Table aliases and CTE / derived / table-function names.
92    pub table_alias: CaseRule,
93    /// Column names and column aliases.
94    pub column: CaseRule,
95}
96
97impl IdentifierCasing {
98    /// One [`CaseRule`] applied to every identifier class.
99    pub const fn uniform(fold: CaseRule) -> Self {
100        Self {
101            table: fold,
102            table_alias: fold,
103            column: fold,
104        }
105    }
106
107    /// Map a parsed dialect to its default casing. Unrecognised
108    /// dialects fall back to the generic policy ([`CaseRule::Lower`]
109    /// everywhere), which preserves the resolver's historical
110    /// behaviour.
111    ///
112    /// Filesystem-dependent (MySQL table names) and collation-dependent
113    /// (SQL Server) models can't be known from the dialect alone, so
114    /// they resolve to a fixed default here: SQL Server to the common
115    /// case-insensitive collation, MySQL table names to the
116    /// false-merge-avoiding [`CaseRule::Sensitive`]. A future override
117    /// API can refine these per deployment.
118    pub fn for_dialect(dialect: &dyn Dialect) -> Self {
119        if dialect.is::<PostgreSqlDialect>() {
120            Self::uniform(CaseRule::Lower)
121        } else if dialect.is::<AnsiDialect>()
122            || dialect.is::<SnowflakeDialect>()
123            || dialect.is::<OracleDialect>()
124        {
125            // Oracle folds nonquoted identifiers to upper-case and keeps
126            // quoted ones case-sensitive — the ANSI rule.
127            Self::uniform(CaseRule::Upper)
128        } else if dialect.is::<DuckDbDialect>()
129            || dialect.is::<SQLiteDialect>()
130            || dialect.is::<HiveDialect>()
131            || dialect.is::<DatabricksDialect>()
132            || dialect.is::<RedshiftSqlDialect>()
133        {
134            // Hive and Databricks / Spark SQL resolve identifiers
135            // case-insensitively by default (`spark.sql.caseSensitive`
136            // defaults to false). Redshift, by default, folds *both*
137            // unquoted and double-quoted identifiers to lower case
138            // (case-insensitive) — `enable_case_sensitive_identifier` is
139            // `false` out of the box; setting it to `true` would make
140            // quoted identifiers case-sensitive (a Lower rule), a
141            // per-deployment override not modelled here.
142            Self::uniform(CaseRule::Insensitive)
143        } else if dialect.is::<MsSqlDialect>() {
144            // Default install collation is case-insensitive (e.g.
145            // `*_CI_AS`); a CS collation would flip every class.
146            Self::uniform(CaseRule::Insensitive)
147        } else if dialect.is::<ClickHouseDialect>() {
148            // All identifiers — database / table / column and aliases —
149            // are case-sensitive (aliases are identifiers too); quoting
150            // handles special characters, not case.
151            Self::uniform(CaseRule::Sensitive)
152        } else if dialect.is::<MySqlDialect>() {
153            // Table names are filesystem-dependent (Unix CS / Win-mac
154            // CI) → Sensitive fallback (avoid merging distinct stored
155            // tables). Aliases are *also* FS-dependent, but they're
156            // statement-local: a mismatched-case alias reference almost
157            // always intends the same alias, so default them to the
158            // lenient Insensitive rather than introduce a phantom
159            // reference. Columns are definitively CI.
160            Self {
161                table: CaseRule::Sensitive,
162                table_alias: CaseRule::Insensitive,
163                column: CaseRule::Insensitive,
164            }
165        } else if dialect.is::<BigQueryDialect>() {
166            // Tables case-sensitive, but aliases and columns are
167            // case-insensitive.
168            Self {
169                table: CaseRule::Sensitive,
170                table_alias: CaseRule::Insensitive,
171                column: CaseRule::Insensitive,
172            }
173        } else {
174            // GenericDialect and anything unrecognised: preserve the
175            // resolver's historical lower-fold behaviour.
176            Self::uniform(CaseRule::Lower)
177        }
178    }
179}
180
181impl Default for IdentifierCasing {
182    fn default() -> Self {
183        Self::uniform(CaseRule::Lower)
184    }
185}
186
187/// The canonical quote character a dialect uses to delimit a
188/// case-exact identifier. Used only when *surfacing* a catalog-confirmed
189/// identity (which is case-exact, so it carries this quote) — it is a
190/// presentation concern, distinct from the fold [`CaseRule`] used for
191/// matching. The choice is the form each engine documents as standard:
192/// backtick for MySQL / BigQuery / Hive / Spark (Databricks), square
193/// brackets for SQL Server, and the SQL-standard double quote for
194/// everything else (including ClickHouse / SQLite, which accept both but
195/// document `"` as the standard form, and the generic fallback).
196///
197/// `sqlparser`'s `Dialect::identifier_quote_style` is *not* used: it
198/// encodes which quote a parser accepts and is unset (`None`) for most
199/// dialects (e.g. BigQuery), so it can't supply a canonical character.
200pub(crate) fn canonical_quote(dialect: &dyn Dialect) -> char {
201    if dialect.is::<MySqlDialect>()
202        || dialect.is::<BigQueryDialect>()
203        || dialect.is::<HiveDialect>()
204        || dialect.is::<DatabricksDialect>()
205    {
206        '`'
207    } else if dialect.is::<MsSqlDialect>() {
208        '['
209    } else {
210        '"'
211    }
212}
213
214/// The full identifier-handling context threaded into the binder: the
215/// per-class fold [`IdentifierCasing`] (a matching policy the caller may
216/// override) plus the dialect's [`canonical_quote`] (a surface decoration
217/// for catalog-confirmed identities, always dialect-derived). Kept
218/// internal — only `IdentifierCasing` is part of the public surface.
219#[derive(Clone, Copy, Debug)]
220pub(crate) struct IdentifierStyle {
221    pub(crate) casing: IdentifierCasing,
222    pub(crate) quote: char,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn unquoted(s: &str) -> Ident {
230        Ident::new(s)
231    }
232
233    fn quoted(s: &str) -> Ident {
234        Ident::with_quote('"', s)
235    }
236
237    /// Two identifiers match under `fold` iff their normalized keys
238    /// are equal.
239    fn matches(fold: CaseRule, a: &Ident, b: &Ident) -> bool {
240        fold.normalize(a) == fold.normalize(b)
241    }
242
243    /// The quoting matrix: each `(binding, reference)` pair is folded
244    /// independently by its own quote flag, then compared. Columns are
245    /// the four folds; `true` = the two identifiers match.
246    ///
247    /// | binding   | reference | Upper | Lower | Insensitive | Sensitive |
248    /// |-----------|-----------|-------|-------|-------------|-----------|
249    /// | `Users`   | `users`   | ✓     | ✓     | ✓           | ✗         |
250    /// | `Users`   | `"users"` | ✗     | ✓     | ✓           | ✗         |
251    /// | `Users`   | `"Users"` | ✗     | ✗     | ✓           | ✓         |
252    /// | `"Users"` | `users`   | ✗     | ✗     | ✓           | ✗         |
253    /// | `"Users"` | `"Users"` | ✓     | ✓     | ✓           | ✓         |
254    /// | `"Users"` | `"users"` | ✗     | ✗     | ✓           | ✗         |
255    #[test]
256    fn quoting_matrix() {
257        use CaseRule::{Insensitive, Lower, Sensitive, Upper};
258
259        // (binding, reference) → [Upper, Lower, Insensitive, Sensitive]
260        let cases: &[(Ident, Ident, [bool; 4])] = &[
261            (
262                unquoted("Users"),
263                unquoted("users"),
264                [true, true, true, false],
265            ),
266            (
267                unquoted("Users"),
268                quoted("users"),
269                [false, true, true, false],
270            ),
271            (
272                unquoted("Users"),
273                quoted("Users"),
274                [false, false, true, true],
275            ),
276            (
277                quoted("Users"),
278                unquoted("users"),
279                [false, false, true, false],
280            ),
281            (quoted("Users"), quoted("Users"), [true, true, true, true]),
282            (
283                quoted("Users"),
284                quoted("users"),
285                [false, false, true, false],
286            ),
287        ];
288
289        for (binding, reference, [up, lo, ci, cs]) in cases {
290            assert_eq!(
291                matches(Upper, binding, reference),
292                *up,
293                "Upper: {binding:?} vs {reference:?}"
294            );
295            assert_eq!(
296                matches(Lower, binding, reference),
297                *lo,
298                "Lower: {binding:?} vs {reference:?}"
299            );
300            assert_eq!(
301                matches(Insensitive, binding, reference),
302                *ci,
303                "Insensitive: {binding:?} vs {reference:?}"
304            );
305            assert_eq!(
306                matches(Sensitive, binding, reference),
307                *cs,
308                "Sensitive: {binding:?} vs {reference:?}"
309            );
310        }
311    }
312
313    /// Each segment carries its own quote flag, so a mixed qualifier
314    /// like `"Schema".table` folds segment-by-segment.
315    #[test]
316    fn per_segment_quoting_is_independent() {
317        // Under Lower: quoted segment preserved, unquoted folded.
318        assert_eq!(CaseRule::Lower.normalize(&quoted("Schema")), "Schema");
319        assert_eq!(CaseRule::Lower.normalize(&unquoted("Table")), "table");
320        // Under Sensitive: quoting ignored, both preserved.
321        assert_eq!(CaseRule::Sensitive.normalize(&quoted("Schema")), "Schema");
322        assert_eq!(CaseRule::Sensitive.normalize(&unquoted("Table")), "Table");
323        // Under Insensitive: quoting ignored, both folded.
324        assert_eq!(CaseRule::Insensitive.normalize(&quoted("Schema")), "schema");
325        assert_eq!(CaseRule::Insensitive.normalize(&unquoted("Table")), "table");
326    }
327
328    /// `for_dialect` maps every recognised dialect to its
329    /// identifier-casing policy. Homogeneous dialects fold every class
330    /// alike; MySQL and BigQuery *split* — real table names are stricter
331    /// (`Sensitive`) than statement-local aliases / columns
332    /// (`Insensitive`). Anything unrecognised falls back to the generic
333    /// lower-fold. Covers all `sqlparser` dialect structs (an `is::<…>()`
334    /// ladder has no compile-time exhaustiveness, so the table stands in
335    /// as arm coverage).
336    ///
337    /// | dialect    | table       | table_alias | column      |
338    /// |------------|-------------|-------------|-------------|
339    /// | PostgreSQL | Lower       | Lower       | Lower       |
340    /// | Redshift   | Insensitive | Insensitive | Insensitive |
341    /// | ANSI       | Upper       | Upper       | Upper       |
342    /// | Snowflake  | Upper       | Upper       | Upper       |
343    /// | Oracle     | Upper       | Upper       | Upper       |
344    /// | DuckDB     | Insensitive | Insensitive | Insensitive |
345    /// | SQLite     | Insensitive | Insensitive | Insensitive |
346    /// | SQL Server | Insensitive | Insensitive | Insensitive |
347    /// | Hive       | Insensitive | Insensitive | Insensitive |
348    /// | Databricks | Insensitive | Insensitive | Insensitive |
349    /// | ClickHouse | Sensitive   | Sensitive   | Sensitive   |
350    /// | MySQL      | Sensitive   | Insensitive | Insensitive |
351    /// | BigQuery   | Sensitive   | Insensitive | Insensitive |
352    /// | Generic    | Lower       | Lower       | Lower       |
353    #[test]
354    fn dialect_casing_matrix() {
355        use sqlparser::dialect::GenericDialect;
356        use CaseRule::{Insensitive, Lower, Sensitive, Upper};
357
358        let uniform = |fold| IdentifierCasing {
359            table: fold,
360            table_alias: fold,
361            column: fold,
362        };
363        // MySQL / BigQuery: real tables stricter than aliases / columns.
364        let split = IdentifierCasing {
365            table: Sensitive,
366            table_alias: Insensitive,
367            column: Insensitive,
368        };
369
370        let cases: Vec<(&str, Box<dyn Dialect>, IdentifierCasing)> = vec![
371            ("PostgreSQL", Box::new(PostgreSqlDialect {}), uniform(Lower)),
372            (
373                "Redshift",
374                Box::new(RedshiftSqlDialect {}),
375                uniform(Insensitive),
376            ),
377            ("ANSI", Box::new(AnsiDialect {}), uniform(Upper)),
378            ("Snowflake", Box::new(SnowflakeDialect {}), uniform(Upper)),
379            ("Oracle", Box::new(OracleDialect {}), uniform(Upper)),
380            ("DuckDB", Box::new(DuckDbDialect {}), uniform(Insensitive)),
381            ("SQLite", Box::new(SQLiteDialect {}), uniform(Insensitive)),
382            (
383                "SQL Server",
384                Box::new(MsSqlDialect {}),
385                uniform(Insensitive),
386            ),
387            ("Hive", Box::new(HiveDialect {}), uniform(Insensitive)),
388            (
389                "Databricks",
390                Box::new(DatabricksDialect {}),
391                uniform(Insensitive),
392            ),
393            (
394                "ClickHouse",
395                Box::new(ClickHouseDialect {}),
396                uniform(Sensitive),
397            ),
398            ("MySQL", Box::new(MySqlDialect {}), split),
399            ("BigQuery", Box::new(BigQueryDialect {}), split),
400            ("Generic", Box::new(GenericDialect {}), uniform(Lower)),
401        ];
402
403        for (name, dialect, expected) in cases {
404            assert_eq!(
405                IdentifierCasing::for_dialect(dialect.as_ref()),
406                expected,
407                "{name}"
408            );
409        }
410    }
411}