Skip to main content

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(&quoted("x"))
219        );
220        assert_ne!(
221            IdentCasing::FoldUnquoted.fold(&id("X")),
222            IdentCasing::FoldUnquoted.fold(&quoted("X"))
223        );
224
225        assert_eq!(
226            IdentCasing::FoldUnquotedUpper.fold(&id("X")),
227            IdentCasing::FoldUnquotedUpper.fold(&quoted("X"))
228        );
229        assert_ne!(
230            IdentCasing::FoldUnquotedUpper.fold(&id("X")),
231            IdentCasing::FoldUnquotedUpper.fold(&quoted("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(&quoted("Temp")),
240            IdentCasing::FoldAll.fold(&quoted("temp"))
241        );
242        assert_ne!(
243            IdentCasing::FoldUnquoted.fold(&quoted("Temp")),
244            IdentCasing::FoldUnquoted.fold(&quoted("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}