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::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(&quoted("Temp")),
183            IdentCasing::FoldAll.fold(&quoted("temp"))
184        );
185        assert_ne!(
186            IdentCasing::FoldUnquoted.fold(&quoted("Temp")),
187            IdentCasing::FoldUnquoted.fold(&quoted("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}