ddx_core/colref.rs
1// SPDX-FileCopyrightText: 2026 Alexander Merose <al@merose.com> & ddx Authors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Column identity read off the AST, compared with per-dialect identifier
6//! folding rather than raw-string equality (design.md §3.2, F1).
7
8use sqlparser::ast::{Expr, Ident};
9
10use crate::error::{DiffError, Result};
11
12/// How a dialect folds identifiers for case-insensitive comparison.
13///
14/// SQL unquoted identifiers are case-insensitive, so `grad(Temp*Temp, temp)`
15/// must match — otherwise it silently differentiates to `0`. The exact rule is
16/// per-dialect (F1):
17///
18/// * [`IdentCasing::FoldUnquoted`] — unquoted identifiers fold to lowercase;
19/// quoted identifiers keep their case. (DataFusion, Postgres, the generic
20/// dialect.)
21/// * [`IdentCasing::FoldUnquotedUpper`] — the same rule with the opposite
22/// target case: unquoted identifiers fold to *uppercase*. (Snowflake, Oracle.)
23/// * [`IdentCasing::FoldAll`] — *all* identifiers fold, quoted included, so
24/// case never distinguishes two columns. (DuckDB, Spark, MySQL.)
25/// * [`IdentCasing::FoldNone`] — no identifier folds; `x` and `X` are simply
26/// different columns. (ClickHouse.)
27///
28/// The two unquoted-only policies are not interchangeable, and the difference is
29/// visible rather than cosmetic: `X` matches `"X"` under `FoldUnquotedUpper` and
30/// `"x"` under `FoldUnquoted`. Applying the wrong one to Snowflake does not just
31/// miss — it can match the *other* column and return a confidently wrong
32/// nonzero derivative.
33// More engines than these three families exist, and each one found is a new
34// variant. Marking the enum non-exhaustive makes that an additive change for
35// anyone matching on it downstream, instead of a breaking release per engine.
36#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum IdentCasing {
39 /// Fold unquoted identifiers to lowercase (DataFusion / Postgres / generic).
40 FoldUnquoted,
41 /// Fold unquoted identifiers to uppercase (Snowflake / Oracle).
42 FoldUnquotedUpper,
43 /// Fold every identifier, quoted included (DuckDB / Spark / MySQL).
44 FoldAll,
45 /// Fold nothing; identifiers are compared as written (ClickHouse).
46 FoldNone,
47}
48
49impl IdentCasing {
50 /// The comparison key for a single identifier under this policy.
51 ///
52 /// Only equality of the returned keys is meaningful — which case they fold
53 /// *to* is arbitrary, so long as an unquoted identifier and a quoted one
54 /// land on the same key exactly when the engine would resolve them to the
55 /// same column.
56 pub fn fold(self, id: &Ident) -> String {
57 match (self, id.quote_style) {
58 // Case-insensitive throughout: quoting changes nothing.
59 (IdentCasing::FoldAll, _) => id.value.to_ascii_lowercase(),
60 // Case-sensitive throughout: nothing folds, quoted or not.
61 (IdentCasing::FoldNone, _) => id.value.clone(),
62 // Quoting pins the case exactly; an unquoted identifier folds to
63 // whichever case the engine normalizes to, and that choice is what
64 // decides which quoted identifiers it then collides with.
65 (_, Some(_)) => id.value.clone(),
66 (IdentCasing::FoldUnquoted, None) => id.value.to_ascii_lowercase(),
67 (IdentCasing::FoldUnquotedUpper, None) => id.value.to_ascii_uppercase(),
68 }
69 }
70}
71
72/// A column reference: an optional qualifier and a name, taken straight off the
73/// AST. Stores `sqlparser` [`Ident`]s (which keep quote-style) and compares
74/// with dialect-aware folding, never raw-string equality.
75#[derive(Debug, Clone)]
76pub struct ColRef {
77 /// The qualifier (`a` in `a.x`), if the reference was compound.
78 pub qualifier: Option<Ident>,
79 /// The column name (`x` in `a.x`, or in a bare `x`).
80 pub name: Ident,
81}
82
83/// Whether a column occurrence *is* the differentiation variable, and if its
84/// identity relative to `wrt` could be established syntactically at all.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Match {
87 /// This occurrence is the `wrt` column — its tangent is the seed.
88 Is,
89 /// This occurrence is definitely a different column — tangent zero.
90 Not,
91 /// The occurrence's base name matches `wrt` but its qualification can't be
92 /// pinned syntactically — a bare occurrence when `wrt` is qualified, or a
93 /// qualified occurrence when `wrt` is bare. Hard error (F2).
94 Ambiguous,
95}
96
97impl ColRef {
98 /// Build a bare (unqualified) column reference by name.
99 pub fn bare(name: impl Into<String>) -> Self {
100 ColRef {
101 qualifier: None,
102 name: Ident::new(name.into()),
103 }
104 }
105
106 /// Read a `ColRef` from a column-reference expression
107 /// (`Identifier`/`CompoundIdentifier`, seeing through a `Nested` wrapper).
108 /// Returns `None` for any expression that is not a column reference.
109 pub fn from_expr(e: &Expr) -> Option<ColRef> {
110 match e {
111 Expr::Identifier(id) => Some(ColRef {
112 qualifier: None,
113 name: id.clone(),
114 }),
115 Expr::CompoundIdentifier(parts) => parts.last().map(|last| {
116 let qualifier = if parts.len() >= 2 {
117 Some(parts[parts.len() - 2].clone())
118 } else {
119 None
120 };
121 ColRef {
122 qualifier,
123 name: last.clone(),
124 }
125 }),
126 Expr::Nested(inner) => ColRef::from_expr(inner),
127 _ => None,
128 }
129 }
130
131 /// Parse the `wrt` argument of a marker: it must be a bare column
132 /// (`Identifier`/`CompoundIdentifier`), never an expression (F: the design
133 /// rejects `grad(x*y, x+y)`).
134 pub fn from_wrt_arg(func: &str, e: &Expr) -> Result<ColRef> {
135 ColRef::from_expr(e).ok_or_else(|| {
136 DiffError::InvalidMarker(format!(
137 "{func}(): the differentiation variable must be a bare column, but got `{e}`. \
138 Differentiate with respect to a single column (e.g. `{func}(x * y, x)`), not an \
139 expression like `x + y`"
140 ))
141 })
142 }
143
144 /// Classify an occurrence `self` against the differentiation variable
145 /// `wrt` under a folding policy — the whole of the ambiguity guard (F2).
146 ///
147 /// The guard fires (returns [`Match::Ambiguous`]) *only* on an uncertain
148 /// occurrence of the `wrt` base name; a non-matching name is always
149 /// [`Match::Not`], and a fully-qualified unambiguous match (e.g. `a.x`
150 /// against `a.x`) is [`Match::Is`] with no error.
151 pub fn classify(&self, wrt: &ColRef, casing: IdentCasing) -> Match {
152 if casing.fold(&self.name) != casing.fold(&wrt.name) {
153 // Different base name — unrelated column, no ambiguity possible.
154 return Match::Not;
155 }
156 match (&self.qualifier, &wrt.qualifier) {
157 // Both qualified: identity is fully determined by the qualifier.
158 (Some(sq), Some(wq)) => {
159 if casing.fold(sq) == casing.fold(wq) {
160 Match::Is
161 } else {
162 Match::Not
163 }
164 }
165 // Both bare, same name: this is the wrt.
166 (None, None) => Match::Is,
167 // A qualified occurrence when wrt is bare, or a bare occurrence
168 // when wrt is qualified: cannot be pinned syntactically.
169 (Some(_), None) | (None, Some(_)) => Match::Ambiguous,
170 }
171 }
172
173 /// Render for error messages (e.g. `a.x` or `x`).
174 pub fn display(&self) -> String {
175 match &self.qualifier {
176 Some(q) => format!("{q}.{}", self.name),
177 None => self.name.to_string(),
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn id(s: &str) -> Ident {
187 Ident::new(s)
188 }
189
190 fn quoted(s: &str) -> Ident {
191 Ident::with_quote('"', s)
192 }
193
194 #[test]
195 fn unquoted_folds_case_in_every_dialect() {
196 assert_eq!(
197 IdentCasing::FoldUnquoted.fold(&id("Temp")),
198 IdentCasing::FoldUnquoted.fold(&id("temp"))
199 );
200 assert_eq!(
201 IdentCasing::FoldAll.fold(&id("Temp")),
202 IdentCasing::FoldAll.fold(&id("temp"))
203 );
204 assert_eq!(
205 IdentCasing::FoldUnquotedUpper.fold(&id("Temp")),
206 IdentCasing::FoldUnquotedUpper.fold(&id("temp"))
207 );
208 }
209
210 #[test]
211 fn an_unquoted_identifier_matches_the_quoting_its_engine_normalizes_to() {
212 // The whole reason FoldUnquotedUpper exists. Postgres resolves bare `X`
213 // to "x"; Snowflake resolves it to "X". Using one engine's rule on the
214 // other does not merely fail to match — it matches the *opposite*
215 // column, which is a wrong nonzero derivative rather than a zero.
216 assert_eq!(
217 IdentCasing::FoldUnquoted.fold(&id("X")),
218 IdentCasing::FoldUnquoted.fold("ed("x"))
219 );
220 assert_ne!(
221 IdentCasing::FoldUnquoted.fold(&id("X")),
222 IdentCasing::FoldUnquoted.fold("ed("X"))
223 );
224
225 assert_eq!(
226 IdentCasing::FoldUnquotedUpper.fold(&id("X")),
227 IdentCasing::FoldUnquotedUpper.fold("ed("X"))
228 );
229 assert_ne!(
230 IdentCasing::FoldUnquotedUpper.fold(&id("X")),
231 IdentCasing::FoldUnquotedUpper.fold("ed("x"))
232 );
233 }
234
235 #[test]
236 fn quoted_folding_is_per_dialect() {
237 // DuckDB folds quoted; DataFusion/Postgres keep case.
238 assert_eq!(
239 IdentCasing::FoldAll.fold("ed("Temp")),
240 IdentCasing::FoldAll.fold("ed("temp"))
241 );
242 assert_ne!(
243 IdentCasing::FoldUnquoted.fold("ed("Temp")),
244 IdentCasing::FoldUnquoted.fold("ed("temp"))
245 );
246 }
247
248 #[test]
249 fn bare_wrt_matches_bare_occurrence() {
250 let x = ColRef::bare("x");
251 assert_eq!(x.classify(&x, IdentCasing::FoldUnquoted), Match::Is);
252 assert_eq!(
253 ColRef::bare("y").classify(&x, IdentCasing::FoldUnquoted),
254 Match::Not
255 );
256 }
257
258 #[test]
259 fn qualified_wrt_disambiguates_across_a_join() {
260 // grad(a.x * b.x, a.x): a.x is the wrt, b.x is a different column.
261 let ax = ColRef {
262 qualifier: Some(id("a")),
263 name: id("x"),
264 };
265 let bx = ColRef {
266 qualifier: Some(id("b")),
267 name: id("x"),
268 };
269 assert_eq!(ax.classify(&ax, IdentCasing::FoldUnquoted), Match::Is);
270 assert_eq!(bx.classify(&ax, IdentCasing::FoldUnquoted), Match::Not);
271 }
272
273 #[test]
274 fn bare_occurrence_with_qualified_wrt_is_ambiguous() {
275 // grad(x * a.x, a.x): bare x might be a.x — demand qualification.
276 let bare_x = ColRef::bare("x");
277 let ax = ColRef {
278 qualifier: Some(id("a")),
279 name: id("x"),
280 };
281 assert_eq!(
282 bare_x.classify(&ax, IdentCasing::FoldUnquoted),
283 Match::Ambiguous
284 );
285 }
286
287 #[test]
288 fn qualified_occurrence_with_bare_wrt_is_ambiguous() {
289 // grad(a.x * b.x, x): bare wrt x, qualified occurrences — ambiguous.
290 let ax = ColRef {
291 qualifier: Some(id("a")),
292 name: id("x"),
293 };
294 let bare_x = ColRef::bare("x");
295 assert_eq!(
296 ax.classify(&bare_x, IdentCasing::FoldUnquoted),
297 Match::Ambiguous
298 );
299 }
300}