Skip to main content

squonk_ast/ast/
pivot.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! DuckDB `PIVOT` / `UNPIVOT` — the row-to-column (and inverse) relational operators.
5//!
6//! DuckDB exposes each operator through *two* surfaces that build the same operator:
7//! the leading-keyword **statement** (`PIVOT t ON year USING sum(x) GROUP BY city`,
8//! `UNPIVOT t ON a, b INTO NAME n VALUE v`) and the SQL-standard **table factor**
9//! written after a relation in `FROM` (`t PIVOT (sum(x) FOR year IN (2000, 2010))`,
10//! `t UNPIVOT (v FOR n IN (a, b))`). The two spellings canonicalize onto
11//! one shape per operator — [`Pivot`] and [`Unpivot`] — carrying a [`PivotSpelling`] /
12//! [`UnpivotSpelling`] tag so rendering reproduces the written surface without a second
13//! node. Each core is hosted in both positions:
14//! [`Statement::Pivot`](crate::ast::Statement)/`Unpivot` (dispatched as a top-level
15//! statement, DuckDB's `PivotStatement`) and
16//! [`TableFactor::Pivot`](crate::ast::TableFactor)/`Unpivot` (the `FROM`-suffix form),
17//! sharing the operator fields while the table-factor position owns the trailing alias.
18//!
19//! Both operators are gated on
20//! [`TableFactorSyntax::pivot`](crate::dialect::TableExpressionSyntax) /
21//! [`unpivot`](crate::dialect::TableExpressionSyntax) (DuckDb/Lenient) and rest on the
22//! reservation of `PIVOT`/`UNPIVOT` (DuckDB `duckdb_keywords()` class `reserved`, like
23//! `QUALIFY`) — without it a trailing `PIVOT` would be swallowed as the source's alias.
24
25use super::{
26    AliasSpelling, Expr, Extension, Ident, Limit, NoExt, OrderByAll, OrderByExpr, Query,
27    TableFactor, With,
28};
29use crate::vocab::Meta;
30use thin_vec::ThinVec;
31
32/// DuckDB's `PIVOT` operator: rotate the distinct values of the pivot column(s) into
33/// columns, aggregating each cell.
34///
35/// One canonical shape for both DuckDB surfaces; [`spelling`](Self::spelling)
36/// records which was written so it round-trips. The fields cover both:
37///
38/// - the statement `PIVOT <source> [ON <pivot_on>] [USING <aggregates>] [GROUP BY
39///   <group_by>]` — any of `ON`/`USING`/`GROUP BY` may be absent, and an `ON` entry may
40///   carry an inline `IN (...)` value list (`ON year IN (2000, 2010)`) or none
41///   (auto-detected at bind time);
42/// - the table factor `<source> PIVOT (<aggregates> FOR <col> IN (…) [<col> IN (…)]…
43///   [GROUP BY <group_by>])` — one `FOR` keyword heading one or more column heads
44///   (the extra heads are written bare; a second `FOR` is an engine syntax error),
45///   each with a required `IN` source, and at least one aggregate (DuckDB
46///   syntax-rejects an empty aggregate list here; both probed on 1.5.4).
47///
48/// `source` is boxed to break the `TableFactor` → `Pivot` → `TableFactor` type cycle
49/// (`FROM (SELECT …) PIVOT (…)` nests a derived table; a bare `PIVOT (SELECT …) ON …`
50/// nests one too). The source keeps its own correlation alias; the *pivot's* alias
51/// (`… PIVOT (…) AS p`) belongs to the enclosing
52/// [`TableFactor::Pivot`](crate::ast::TableFactor), never to a statement.
53#[derive(Clone, Debug, PartialEq, Eq, Hash)]
54#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
55#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
56pub struct Pivot<X: Extension = NoExt> {
57    /// Input source for this syntax.
58    pub source: Box<TableFactor<X>>,
59    /// The `USING` aggregate list (statement) / the leading `(<aggregates> FOR …)` list
60    /// (table factor); each an expression with an optional output-name alias. Empty for
61    /// the aggregate-less statement forms (`PIVOT t ON year`, `PIVOT t GROUP BY city`).
62    pub aggregates: ThinVec<PivotExpr<X>>,
63    /// The pivot column(s): `ON <col>, …` (statement, one entry per comma item, each
64    /// with an optional inline `IN (...)`) or the `FOR`-headed column list (table
65    /// factor). Empty when the statement writes no `ON`.
66    pub pivot_on: ThinVec<PivotColumn<X>>,
67    /// Grouping terms in source order.
68    pub group_by: ThinVec<Expr<X>>,
69    /// The `WITH` clause prefixing a *statement*-spelled pivot (`WITH c AS (…) PIVOT c
70    /// ON …`): DuckDB attaches the CTEs to the pivot statement itself, exactly as it
71    /// does for INSERT/UPDATE/DELETE. Always `None` in the table-factor spelling, whose
72    /// enclosing query owns any `WITH`.
73    pub with: Option<With<X>>,
74    /// The statement form's trailing `ORDER BY` keys (`PIVOT t USING sum(x) ORDER BY
75    /// c`, engine-verified); always empty in the table-factor spelling, where ordering
76    /// belongs to the enclosing SELECT.
77    pub order_by: ThinVec<OrderByExpr<X>>,
78    /// The statement form's `ORDER BY ALL` clause mode (engine-verified) — mutually
79    /// exclusive with a non-empty [`order_by`](Self::order_by), exactly as on
80    /// [`Query`]. Always `None` in the table-factor spelling.
81    pub order_by_all: Option<Box<OrderByAll>>,
82    /// The statement form's trailing `LIMIT`/`OFFSET` tail (engine-verified); boxed
83    /// because the tail is rare while [`Limit`] is wide. Always `None` in
84    /// the table-factor spelling.
85    pub limit: Option<Box<Limit<X>>>,
86    /// The Snowflake table-factor `DEFAULT ON NULL (<expr>)` tail — the value
87    /// substituted for a pivoted cell that would otherwise be `NULL`
88    /// (`PIVOT (sum(x) FOR y IN (…) DEFAULT ON NULL (0))`). Gated on
89    /// [`TableFactorSyntax::pivot_value_sources`](crate::dialect::TableFactorSyntax);
90    /// `None` in the DuckDB surfaces (which have no such clause) and in any dialect
91    /// off the gate. Boxed because the clause is rare while [`Expr`] is wide.
92    pub default_on_null: Option<Box<Expr<X>>>,
93    /// Which surface produced this node — drives rendering.
94    pub spelling: PivotSpelling,
95    /// Source location and node identity.
96    pub meta: Meta,
97}
98
99/// The `UNPIVOT` operator: collapse a set of columns into `NAME`/`VALUE` row pairs
100/// (the inverse of [`Pivot`]).
101///
102/// One canonical shape for both surfaces; [`spelling`](Self::spelling)
103/// records which was written. The fields cover both:
104///
105/// - the DuckDB statement `UNPIVOT <source> ON <columns> [INTO NAME <name> VALUE
106///   <value>]` — the `INTO` clause renames the output name/value columns (absent leaves
107///   DuckDB's default `name`/`value`);
108/// - the table factor `<source> UNPIVOT [INCLUDE|EXCLUDE NULLS] (<value> FOR <name> IN
109///   (<columns>))` — the shared DuckDB/BigQuery/Snowflake surface (the standard is
110///   reachable off the DuckDB [`unpivot`](crate::dialect::TableFactorSyntax::unpivot)
111///   flag under
112///   [`pivot_value_sources`](crate::dialect::TableFactorSyntax::pivot_value_sources)).
113///   The `NULLS` marker is table-factor-only (the statement form rejects it, so
114///   [`null_inclusion`](Self::null_inclusion) is always `None` there).
115///
116/// [`value`](Self::value) and [`name`](Self::name) are lists because DuckDB admits a
117/// multi-column unpivot (`(v1, v2) FOR n IN ((a, b), (c, d))`), whose value name list
118/// has more than one entry; the common form fills each with a single [`Ident`].
119#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
121#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
122pub struct Unpivot<X: Extension = NoExt> {
123    /// Input source for this syntax.
124    pub source: Box<TableFactor<X>>,
125    /// The output *value* column name(s) (`INTO … VALUE v` / the `v` in `(v FOR n IN
126    /// …)`); empty when a statement writes no `INTO`.
127    pub value: ThinVec<Ident>,
128    /// The output *name* column name(s) (`INTO NAME n …` / the `n` in `(v FOR n IN …)`);
129    /// empty when a statement writes no `INTO`.
130    pub name: ThinVec<Ident>,
131    /// The columns being unpivoted: `ON <col>, …` (statement) / the `IN (<cols>)` list
132    /// (table factor). Each entry is one or more column expressions (a grouped
133    /// `(a, b)`) with an optional alias.
134    pub columns: ThinVec<UnpivotColumn<X>>,
135    /// The explicit `INCLUDE NULLS` / `EXCLUDE NULLS` marker (table factor only). `None`
136    /// is the unwritten default (`EXCLUDE NULLS` semantics, rendered bare); `Some` records
137    /// a written marker so it round-trips — including an explicit `EXCLUDE NULLS`, which
138    /// would otherwise elide to the default. Always `None` in the statement spelling,
139    /// which rejects the marker.
140    pub null_inclusion: Option<NullInclusion>,
141    /// The `WITH` clause prefixing a *statement*-spelled unpivot; always `None` in the
142    /// table-factor spelling (the [`Pivot::with`] mirror).
143    pub with: Option<With<X>>,
144    /// The statement form's trailing `ORDER BY` keys; always empty in the table-factor
145    /// spelling (the [`Pivot::order_by`] mirror).
146    pub order_by: ThinVec<OrderByExpr<X>>,
147    /// The statement form's `ORDER BY ALL` clause mode (the [`Pivot::order_by_all`]
148    /// mirror); always `None` in the table-factor spelling.
149    pub order_by_all: Option<Box<OrderByAll>>,
150    /// The statement form's trailing `LIMIT`/`OFFSET` tail; always `None` in the
151    /// table-factor spelling (the [`Pivot::limit`] mirror).
152    pub limit: Option<Box<Limit<X>>>,
153    /// Which surface produced this node — drives rendering.
154    pub spelling: UnpivotSpelling,
155    /// Source location and node identity.
156    pub meta: Meta,
157}
158
159/// A table-factor `UNPIVOT`'s explicit null-row treatment — the `INCLUDE NULLS` /
160/// `EXCLUDE NULLS` marker shared by DuckDB, BigQuery, and Snowflake.
161///
162/// `EXCLUDE NULLS` is every engine's default, so [`Unpivot::null_inclusion`] wraps this
163/// in an `Option`: `None` is the unwritten default and each variant records the marker as
164/// written so it round-trips (an explicit `EXCLUDE NULLS` is preserved rather than elided
165/// to the bare default). A fieldless presence tag — the [`UnpivotSpelling`] precedent, and
166/// the direct sqlparser-rs `NullInclusion` parity.
167#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
168#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
169#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
170pub enum NullInclusion {
171    /// `INCLUDE NULLS` — keep output rows whose unpivoted value is `NULL`.
172    IncludeNulls,
173    /// `EXCLUDE NULLS` — drop null-valued rows (every engine's default; recorded only
174    /// when written so the spelling round-trips).
175    ExcludeNulls,
176}
177
178/// An aliased expression inside a [`Pivot`]: a `USING` aggregate (`sum(x) AS total`) or
179/// an `IN`-list value (`2000 AS y2000`).
180///
181/// One shape for both because they are identical surface — an expression with an
182/// optional output-name alias. The alias may be written as an identifier or a string
183/// literal (`2000 AS 'y2k'`), recorded via the [`Ident`]'s
184/// [`QuoteStyle::Single`](super::QuoteStyle) exactly like MySQL's string projection
185/// aliases, so the spelling round-trips.
186#[derive(Clone, Debug, PartialEq, Eq, Hash)]
187#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
188#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
189pub struct PivotExpr<X: Extension = NoExt> {
190    /// Expression evaluated by this syntax.
191    pub expr: Expr<X>,
192    /// Alias assigned by this syntax.
193    pub alias: Option<Ident>,
194    /// How the source introduced `alias` (`sum(x) AS total` vs `sum(x) total`).
195    /// Meaningful only when `alias` is `Some`; [`AliasSpelling::As`] otherwise.
196    pub alias_spelling: AliasSpelling,
197    /// Source location and node identity.
198    pub meta: Meta,
199}
200
201/// One pivot column and its optional `IN` source — a statement `ON` entry (`year`,
202/// `year IN (2000, 2010)`, `year IN (SELECT …)`) or a table-factor `FOR` head
203/// (`FOR <col> IN (<values>)`, `FOR <col> IN <enum>`,
204/// `FOR <col> IN (ANY [ORDER BY …])`, `FOR <col> IN (<subquery>)`).
205///
206/// The value source is spread across three mutually-exclusive fields rather than one
207/// enum: [`values`](Self::values) (the explicit list) and
208/// [`enum_source`](Self::enum_source) are DuckDB's native shapes and stay untouched,
209/// while [`value_source`](Self::value_source) carries the standard `ANY`/subquery forms
210/// added for the Snowflake/BigQuery/Oracle table factor. This deliberately deviates from
211/// sqlparser-rs's single `PivotValueSource { List, Any, Subquery }` enum: that shape
212/// lives at the whole-pivot level and cannot express DuckDB's *per-column* `FOR y IN (…)
213/// m IN (…)` heads or its `IN <enum>` form, so this node keeps the source per column and
214/// folds the two new forms into an `Option` beside the existing DuckDB fields (see
215/// [`PivotValueSource`]). Exactly one of the three is populated for any one column.
216#[derive(Clone, Debug, PartialEq, Eq, Hash)]
217#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
218#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
219pub struct PivotColumn<X: Extension = NoExt> {
220    /// The pivoted column or expression (a grouped `(a, b)` reads as a row
221    /// expression). A statement entry whose `IN` source is a subquery keeps the whole
222    /// [`Expr::InSubquery`](super::Expr) here — the engine admits it (probed on
223    /// 1.5.4) and the subquery is a value *source*, not a value list.
224    pub expr: Expr<X>,
225    /// The explicit `IN (<values>)` list; empty for a statement `ON` column whose
226    /// values DuckDB auto-detects at bind time (and whenever
227    /// [`enum_source`](Self::enum_source) or [`value_source`](Self::value_source)
228    /// carries the `IN` instead).
229    pub values: ThinVec<PivotExpr<X>>,
230    /// The table factor's `IN <enum>` form (`FOR m IN month_enum`): the values come
231    /// from a named ENUM type rather than a written list. A single unqualified name —
232    /// the engine rejects a qualified one (probed on 1.5.4) — and mutually exclusive
233    /// with [`values`](Self::values).
234    pub enum_source: Option<Ident>,
235    /// The standard PIVOT's non-list value sources — `IN (ANY [ORDER BY …])` and
236    /// `IN (<subquery>)` (Snowflake/BigQuery/Oracle), gated on
237    /// [`TableFactorSyntax::pivot_value_sources`](crate::dialect::TableFactorSyntax).
238    /// `None` for the explicit-list, enum, and auto-detected DuckDB forms; mutually
239    /// exclusive with [`values`](Self::values)/[`enum_source`](Self::enum_source).
240    /// Boxed to keep the common (list) column small while the source is wide.
241    pub value_source: Option<Box<PivotValueSource<X>>>,
242    /// Source location and node identity.
243    pub meta: Meta,
244}
245
246/// A standard PIVOT table factor's non-list `IN` value source — the Snowflake/BigQuery/
247/// Oracle forms beyond an explicit value list.
248///
249/// The explicit list (`IN (v1 [AS a], …)`) stays on [`PivotColumn::values`] and DuckDB's
250/// `IN <enum>` on [`PivotColumn::enum_source`]; this enum carries only the two forms
251/// those fields cannot: the wildcard `ANY` and a value-supplying subquery. It mirrors the
252/// `Any`/`Subquery` arms of sqlparser-rs's `PivotValueSource` (the `List` arm is our
253/// `values` field — see [`PivotColumn`]).
254#[derive(Clone, Debug, PartialEq, Eq, Hash)]
255#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
256#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
257pub enum PivotValueSource<X: Extension = NoExt> {
258    /// `IN (ANY [ORDER BY <keys>])` — pivot on every distinct value of the column, in
259    /// the optional key order (Snowflake/Oracle). The `order_by` list is empty for a
260    /// bare `ANY`.
261    Any {
262        /// Ordering terms in source order.
263        order_by: ThinVec<OrderByExpr<X>>,
264        /// Source location and node identity.
265        meta: Meta,
266    },
267    /// `IN (<subquery>)` — pivot on the values a subquery returns
268    /// (`FOR q IN (SELECT DISTINCT quarter FROM sales)`; Snowflake/Oracle). Boxed to
269    /// break the `TableFactor` → `Pivot` → `Query` → `TableFactor` type cycle.
270    Subquery {
271        /// Query governed by this node.
272        query: Box<Query<X>>,
273        /// Source location and node identity.
274        meta: Meta,
275    },
276}
277
278/// One `UNPIVOT` column entry: a statement `ON` item or an `IN`-list entry, holding one
279/// column (`a`) or a group (`(a, b)`), with an optional alias (`(a, b) AS ab`).
280#[derive(Clone, Debug, PartialEq, Eq, Hash)]
281#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
282#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
283pub struct UnpivotColumn<X: Extension = NoExt> {
284    /// Columns in source order.
285    pub columns: ThinVec<Expr<X>>,
286    /// The `AS <alias>` name for the group; `None` when unwritten. An identifier or, as
287    /// DuckDB admits in this position, a string literal (`(Q1, Q2) AS 'sem1'`) recorded
288    /// via [`QuoteStyle::Single`](super::QuoteStyle) so the spelling round-trips.
289    pub alias: Option<Ident>,
290    /// How the source introduced `alias` (`mar AS q1` vs `mar q1`). Meaningful only when
291    /// `alias` is `Some`; [`AliasSpelling::As`] otherwise.
292    pub alias_spelling: AliasSpelling,
293    /// Source location and node identity.
294    pub meta: Meta,
295}
296
297/// Which surface produced a [`Pivot`] — the leading-keyword statement or the `FROM`
298/// table factor. One operator, two spellings kept as data (the
299/// [`SelectSpelling`](crate::ast::SelectSpelling) precedent); the renderer re-emits the
300/// written form (`PIVOT t ON …` vs `t PIVOT (… FOR … IN …)`).
301#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
302#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
303#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
304pub enum PivotSpelling {
305    /// Source used the `STATEMENT` spelling.
306    Statement,
307    /// Source used the `TABLE FACTOR` spelling.
308    TableFactor,
309}
310
311/// Which surface produced an [`Unpivot`] — the mirror of [`PivotSpelling`].
312#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
313#[cfg_attr(feature = "serde-serialize", derive(serde::Serialize))]
314#[cfg_attr(feature = "serde-deserialize", derive(serde::Deserialize))]
315pub enum UnpivotSpelling {
316    /// Source used the `STATEMENT` spelling.
317    Statement,
318    /// The `FROM` table factor `<source> UNPIVOT [… NULLS] (<value> FOR <name> IN
319    /// (<cols>))`.
320    TableFactor,
321}