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::FoldAll`] — *all* identifiers fold to lowercase, quoted
22/// included. (DuckDB, which is fully case-insensitive.)
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum IdentCasing {
25 /// Fold unquoted identifiers only (DataFusion / Postgres / generic).
26 FoldUnquoted,
27 /// Fold every identifier, quoted included (DuckDB).
28 FoldAll,
29}
30
31impl IdentCasing {
32 /// The comparison key for a single identifier under this policy.
33 pub fn fold(self, id: &Ident) -> String {
34 match (id.quote_style, self) {
35 // Unquoted: always case-folded.
36 (None, _) => id.value.to_ascii_lowercase(),
37 // Quoted: folded only for DuckDB.
38 (Some(_), IdentCasing::FoldAll) => id.value.to_ascii_lowercase(),
39 (Some(_), IdentCasing::FoldUnquoted) => id.value.clone(),
40 }
41 }
42}
43
44/// A column reference: an optional qualifier and a name, taken straight off the
45/// AST. Stores `sqlparser` [`Ident`]s (which keep quote-style) and compares
46/// with dialect-aware folding, never raw-string equality.
47#[derive(Debug, Clone)]
48pub struct ColRef {
49 /// The qualifier (`a` in `a.x`), if the reference was compound.
50 pub qualifier: Option<Ident>,
51 /// The column name (`x` in `a.x`, or in a bare `x`).
52 pub name: Ident,
53}
54
55/// Whether a column occurrence *is* the differentiation variable, and if its
56/// identity relative to `wrt` could be established syntactically at all.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Match {
59 /// This occurrence is the `wrt` column — its tangent is the seed.
60 Is,
61 /// This occurrence is definitely a different column — tangent zero.
62 Not,
63 /// The occurrence's base name matches `wrt` but its qualification can't be
64 /// pinned syntactically — a bare occurrence when `wrt` is qualified, or a
65 /// qualified occurrence when `wrt` is bare. Hard error (F2).
66 Ambiguous,
67}
68
69impl ColRef {
70 /// Build a bare (unqualified) column reference by name.
71 pub fn bare(name: impl Into<String>) -> Self {
72 ColRef {
73 qualifier: None,
74 name: Ident::new(name.into()),
75 }
76 }
77
78 /// Read a `ColRef` from a column-reference expression
79 /// (`Identifier`/`CompoundIdentifier`, seeing through a `Nested` wrapper).
80 /// Returns `None` for any expression that is not a column reference.
81 pub fn from_expr(e: &Expr) -> Option<ColRef> {
82 match e {
83 Expr::Identifier(id) => Some(ColRef {
84 qualifier: None,
85 name: id.clone(),
86 }),
87 Expr::CompoundIdentifier(parts) => parts.last().map(|last| {
88 let qualifier = if parts.len() >= 2 {
89 Some(parts[parts.len() - 2].clone())
90 } else {
91 None
92 };
93 ColRef {
94 qualifier,
95 name: last.clone(),
96 }
97 }),
98 Expr::Nested(inner) => ColRef::from_expr(inner),
99 _ => None,
100 }
101 }
102
103 /// Parse the `wrt` argument of a marker: it must be a bare column
104 /// (`Identifier`/`CompoundIdentifier`), never an expression (F: the design
105 /// rejects `grad(x*y, x+y)`).
106 pub fn from_wrt_arg(func: &str, e: &Expr) -> Result<ColRef> {
107 ColRef::from_expr(e).ok_or_else(|| {
108 DiffError::InvalidMarker(format!(
109 "{func}(): the differentiation variable must be a bare column, but got `{e}`. \
110 Differentiate with respect to a single column (e.g. `{func}(x * y, x)`), not an \
111 expression like `x + y`"
112 ))
113 })
114 }
115
116 /// Classify an occurrence `self` against the differentiation variable
117 /// `wrt` under a folding policy — the whole of the ambiguity guard (F2).
118 ///
119 /// The guard fires (returns [`Match::Ambiguous`]) *only* on an uncertain
120 /// occurrence of the `wrt` base name; a non-matching name is always
121 /// [`Match::Not`], and a fully-qualified unambiguous match (e.g. `a.x`
122 /// against `a.x`) is [`Match::Is`] with no error.
123 pub fn classify(&self, wrt: &ColRef, casing: IdentCasing) -> Match {
124 if casing.fold(&self.name) != casing.fold(&wrt.name) {
125 // Different base name — unrelated column, no ambiguity possible.
126 return Match::Not;
127 }
128 match (&self.qualifier, &wrt.qualifier) {
129 // Both qualified: identity is fully determined by the qualifier.
130 (Some(sq), Some(wq)) => {
131 if casing.fold(sq) == casing.fold(wq) {
132 Match::Is
133 } else {
134 Match::Not
135 }
136 }
137 // Both bare, same name: this is the wrt.
138 (None, None) => Match::Is,
139 // A qualified occurrence when wrt is bare, or a bare occurrence
140 // when wrt is qualified: cannot be pinned syntactically.
141 (Some(_), None) | (None, Some(_)) => Match::Ambiguous,
142 }
143 }
144
145 /// Render for error messages (e.g. `a.x` or `x`).
146 pub fn display(&self) -> String {
147 match &self.qualifier {
148 Some(q) => format!("{q}.{}", self.name),
149 None => self.name.to_string(),
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn id(s: &str) -> Ident {
159 Ident::new(s)
160 }
161
162 fn quoted(s: &str) -> Ident {
163 Ident::with_quote('"', s)
164 }
165
166 #[test]
167 fn unquoted_folds_case_in_every_dialect() {
168 assert_eq!(
169 IdentCasing::FoldUnquoted.fold(&id("Temp")),
170 IdentCasing::FoldUnquoted.fold(&id("temp"))
171 );
172 assert_eq!(
173 IdentCasing::FoldAll.fold(&id("Temp")),
174 IdentCasing::FoldAll.fold(&id("temp"))
175 );
176 }
177
178 #[test]
179 fn quoted_folding_is_per_dialect() {
180 // DuckDB folds quoted; DataFusion/Postgres keep case.
181 assert_eq!(
182 IdentCasing::FoldAll.fold("ed("Temp")),
183 IdentCasing::FoldAll.fold("ed("temp"))
184 );
185 assert_ne!(
186 IdentCasing::FoldUnquoted.fold("ed("Temp")),
187 IdentCasing::FoldUnquoted.fold("ed("temp"))
188 );
189 }
190
191 #[test]
192 fn bare_wrt_matches_bare_occurrence() {
193 let x = ColRef::bare("x");
194 assert_eq!(x.classify(&x, IdentCasing::FoldUnquoted), Match::Is);
195 assert_eq!(
196 ColRef::bare("y").classify(&x, IdentCasing::FoldUnquoted),
197 Match::Not
198 );
199 }
200
201 #[test]
202 fn qualified_wrt_disambiguates_across_a_join() {
203 // grad(a.x * b.x, a.x): a.x is the wrt, b.x is a different column.
204 let ax = ColRef {
205 qualifier: Some(id("a")),
206 name: id("x"),
207 };
208 let bx = ColRef {
209 qualifier: Some(id("b")),
210 name: id("x"),
211 };
212 assert_eq!(ax.classify(&ax, IdentCasing::FoldUnquoted), Match::Is);
213 assert_eq!(bx.classify(&ax, IdentCasing::FoldUnquoted), Match::Not);
214 }
215
216 #[test]
217 fn bare_occurrence_with_qualified_wrt_is_ambiguous() {
218 // grad(x * a.x, a.x): bare x might be a.x — demand qualification.
219 let bare_x = ColRef::bare("x");
220 let ax = ColRef {
221 qualifier: Some(id("a")),
222 name: id("x"),
223 };
224 assert_eq!(
225 bare_x.classify(&ax, IdentCasing::FoldUnquoted),
226 Match::Ambiguous
227 );
228 }
229
230 #[test]
231 fn qualified_occurrence_with_bare_wrt_is_ambiguous() {
232 // grad(a.x * b.x, x): bare wrt x, qualified occurrences — ambiguous.
233 let ax = ColRef {
234 qualifier: Some(id("a")),
235 name: id("x"),
236 };
237 let bare_x = ColRef::bare("x");
238 assert_eq!(
239 ax.classify(&bare_x, IdentCasing::FoldUnquoted),
240 Match::Ambiguous
241 );
242 }
243}