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("ed("Schema")), "Schema");
319 assert_eq!(CaseRule::Lower.normalize(&unquoted("Table")), "table");
320 // Under Sensitive: quoting ignored, both preserved.
321 assert_eq!(CaseRule::Sensitive.normalize("ed("Schema")), "Schema");
322 assert_eq!(CaseRule::Sensitive.normalize(&unquoted("Table")), "Table");
323 // Under Insensitive: quoting ignored, both folded.
324 assert_eq!(CaseRule::Insensitive.normalize("ed("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}