Skip to content

Commit 9bbcb5e

Browse files
committed
Implemented PIVOT / UNPIVOT table operators.
1 parent 2a1dae6 commit 9bbcb5e

8 files changed

Lines changed: 729 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
138138
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
139139
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
140140
- **JOIN / APPLY** — INNER / LEFT / RIGHT / FULL / CROSS + CROSS/OUTER APPLY, ANSI-89 comma-FROM, EF `LeftJoin`/`RightJoin` routing (no LINQ `FullJoin`, so FULL is raw-SQL-only); `JoinDriver` equi-join hash fast path + `SqlValueKey` keying, nested-loop fallback, RIGHT/FULL materialization + derived-table-right, `JoinDiagnostics` strategy guard → [`joins.md`](docs/claude/joins.md).
141+
- **`PIVOT` / `UNPIVOT`** table operators — PIVOT desugars to grouped conditional aggregation (implicit grouping = all inner columns except FOR + aggregate-arg), UNPIVOT is a NULL-skipping unfold; both attach as a postfix FROM-source wrapper and ride the derived-table `LateralPlan` seam → [`pivot.md`](docs/claude/pivot.md).
141142
- **UPDATE / DELETE / INSERT…SELECT / SELECT…INTO / rowversion (incl. `@@DBTS` / `MIN_ACTIVE_ROWVERSION`) / identity helpers (`@@IDENTITY` / `SCOPE_IDENTITY` / `IDENT_CURRENT` / `IDENT_INCR` / `IDENT_SEED`) / `@@ROWCOUNT` / `ROWCOUNT_BIG` / OUTPUT / MERGE**[`dml.md`](docs/claude/dml.md).
142143
- **Variables, control flow (IF/WHILE/BREAK/CONTINUE/RETURN), TRY/CATCH+THROW+ERROR_*, `@@ERROR` / `@@TRANCOUNT` / `XACT_STATE`, PRINT, WAITFOR**[`control-flow.md`](docs/claude/control-flow.md).
143144
- **Cursors (`DECLARE … CURSOR` / `OPEN` / `FETCH` / `CLOSE` / `DEALLOCATE`, STATIC / KEYSET / DYNAMIC sensitivity, scroll fetches, `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS`, `WHERE CURRENT OF`)**[`cursors.md`](docs/claude/cursors.md).
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for the <c>PIVOT</c> / <c>UNPIVOT</c> table operators.
8+
/// PIVOT desugars to grouped conditional aggregation (grouping key = every
9+
/// inner column except the FOR column and the aggregate argument), each IN
10+
/// value becoming <c>agg(CASE forCol WHEN value THEN argCol END)</c>; UNPIVOT
11+
/// unfolds each row into one row per non-NULL IN column. Both attach as a
12+
/// postfix to a FROM source and behave like a derived table downstream.
13+
/// Behavior probed against SQL Server 2025.
14+
/// </summary>
15+
[TestClass]
16+
public sealed class PivotTests
17+
{
18+
private static DbConnection SeededSales()
19+
{
20+
var connection = new Simulation().CreateOpenConnection();
21+
_ = connection.CreateCommand(
22+
"create table sales (Region varchar(10), Yr int, Amount decimal(10,2), Note varchar(20))").ExecuteNonQuery();
23+
_ = connection.CreateCommand(
24+
"insert sales values " +
25+
"('East', 2020, 100.00, 'a'), ('East', 2020, 50.00, 'a'), " +
26+
"('East', 2021, 200.00, 'b'), ('West', 2020, 10.00, 'a'), " +
27+
"('West', 2021, 20.00, 'b'), ('West', 2022, 5.00, 'c')").ExecuteNonQuery();
28+
return connection;
29+
}
30+
31+
// === PIVOT ===
32+
33+
[TestMethod]
34+
public void Pivot_DerivedTable_GroupsByRemainingColumn()
35+
{
36+
// Inner projection drops Note, so the grouping key is Region alone:
37+
// one row per region, each year folded into its own column.
38+
using var connection = SeededSales();
39+
const string pivot =
40+
"(select Region, Yr, Amount from sales) src " +
41+
"pivot (sum(Amount) for Yr in ([2020],[2021],[2022])) as p";
42+
AreEqual(2, connection.CreateCommand($"select count(*) from {pivot}").ExecuteScalar());
43+
AreEqual(150.00m, connection.CreateCommand($"select [2020] from {pivot} where Region = 'East'").ExecuteScalar());
44+
AreEqual(200.00m, connection.CreateCommand($"select [2021] from {pivot} where Region = 'East'").ExecuteScalar());
45+
AreEqual(5.00m, connection.CreateCommand($"select [2022] from {pivot} where Region = 'West'").ExecuteScalar());
46+
}
47+
48+
[TestMethod]
49+
public void Pivot_StrayColumn_SplitsGroups()
50+
{
51+
// SELECT * over the bare table keeps Note, so (Region, Note) is the
52+
// implicit grouping key — East splits into the 'a' and 'b' groups.
53+
using var connection = SeededSales();
54+
AreEqual(5, connection.CreateCommand(
55+
"select count(*) from " +
56+
"(select Region, Note, Yr, Amount from sales) src " +
57+
"pivot (sum(Amount) for Yr in ([2020],[2021],[2022])) as p").ExecuteScalar());
58+
}
59+
60+
[TestMethod]
61+
public void Pivot_EmptyGroup_SumIsNullCountIsZero()
62+
{
63+
using var connection = SeededSales();
64+
// East has no 2022 row for SUM → NULL...
65+
AreEqual(DBNull.Value, connection.CreateCommand(
66+
"select [2022] from (select Region, Yr, Amount from sales) src " +
67+
"pivot (sum(Amount) for Yr in ([2022])) as p where Region = 'East'").ExecuteScalar());
68+
// ...but COUNT over an empty group is 0, not NULL.
69+
AreEqual(0, connection.CreateCommand(
70+
"select [2022] from (select Region, Yr from sales) src " +
71+
"pivot (count(Yr) for Yr in ([2022])) as p where Region = 'East'").ExecuteScalar());
72+
}
73+
74+
[TestMethod]
75+
public void Pivot_ValueNotInSource_ColumnOfNulls()
76+
{
77+
using var connection = SeededSales();
78+
AreEqual(DBNull.Value, connection.CreateCommand(
79+
"select [2099] from (select Region, Yr, Amount from sales) src " +
80+
"pivot (sum(Amount) for Yr in ([2099])) as p where Region = 'East'").ExecuteScalar());
81+
}
82+
83+
[TestMethod]
84+
public void Pivot_StringForColumn()
85+
{
86+
using var connection = SeededSales();
87+
AreEqual(150.00m, connection.CreateCommand(
88+
"select [East] from (select Yr, Region, Amount from sales) src " +
89+
"pivot (sum(Amount) for Region in ([East],[West])) as p where Yr = 2020").ExecuteScalar());
90+
}
91+
92+
[TestMethod]
93+
public void Pivot_AvgAggregate_WidensScale()
94+
{
95+
// AVG(decimal(10,2)) → decimal(38,6): East's 2020 avg of 100 and 50.
96+
using var connection = SeededSales();
97+
AreEqual(75.000000m, connection.CreateCommand(
98+
"select [2020] from (select Region, Yr, Amount from sales) src " +
99+
"pivot (avg(Amount) for Yr in ([2020])) as p where Region = 'East'").ExecuteScalar());
100+
}
101+
102+
[TestMethod]
103+
public void Pivot_WhereAndOrderByApplyToOutput()
104+
{
105+
using var connection = SeededSales();
106+
using var reader = connection.CreateCommand(
107+
"select Region from (select Region, Yr, Amount from sales) src " +
108+
"pivot (sum(Amount) for Yr in ([2020],[2021])) as p " +
109+
"where [2020] > 50 order by [2020] desc").ExecuteReader();
110+
var regions = new List<string>();
111+
while (reader.Read()) regions.Add(reader.GetString(0));
112+
CollectionAssert.AreEqual(new[] { "East" }, regions);
113+
}
114+
115+
[TestMethod]
116+
public void Pivot_ComputedForColumn_AdventureWorksShape()
117+
{
118+
// The shape of Sales.vSalesPersonSalesByFiscalYears: a derived table
119+
// projects YEAR(OrderDate) as the FOR column plus passthrough name
120+
// columns, pivoting SUM(SubTotal) across fiscal years.
121+
var connection = new Simulation().CreateOpenConnection();
122+
_ = connection.CreateCommand(
123+
"create table orders (SalesPersonId int, FullName varchar(20), OrderDate date, SubTotal decimal(10,2))").ExecuteNonQuery();
124+
_ = connection.CreateCommand(
125+
"insert orders values " +
126+
"(1, 'Amy', '2002-03-01', 100.00), (1, 'Amy', '2002-09-01', 50.00), " +
127+
"(1, 'Amy', '2003-01-01', 200.00), (2, 'Bob', '2004-06-01', 7.00)").ExecuteNonQuery();
128+
const string pivot =
129+
"(select SalesPersonId, FullName, year(OrderDate) as FiscalYear, SubTotal from orders) soh " +
130+
"pivot (sum(SubTotal) for FiscalYear in ([2002],[2003],[2004])) as pvt";
131+
AreEqual(150.00m, connection.CreateCommand(
132+
$"select [2002] from {pivot} where SalesPersonId = 1").ExecuteScalar());
133+
AreEqual(7.00m, connection.CreateCommand(
134+
$"select [2004] from {pivot} where FullName = 'Bob'").ExecuteScalar());
135+
AreEqual(DBNull.Value, connection.CreateCommand(
136+
$"select [2003] from {pivot} where FullName = 'Bob'").ExecuteScalar());
137+
connection.Dispose();
138+
}
139+
140+
// === PIVOT error paths ===
141+
142+
[TestMethod]
143+
public void Pivot_CountStar_Rejected()
144+
=> _ = new Simulation().AssertSqlError(
145+
"create table t (a int, b int); " +
146+
"select * from (select a, b from t) s pivot (count(*) for b in ([1])) p", 102);
147+
148+
[TestMethod]
149+
public void Pivot_TwoAggregates_Rejected()
150+
=> _ = new Simulation().AssertSqlError(
151+
"create table t (a int, b int, c int); " +
152+
"select * from (select a, b, c from t) s pivot (sum(c), count(c) for b in ([1])) p", 102);
153+
154+
[TestMethod]
155+
public void Pivot_MissingAlias_Rejected()
156+
=> _ = new Simulation().AssertSqlError(
157+
"create table t (a int, b int, c int); " +
158+
"select * from (select a, b, c from t) s pivot (sum(c) for b in ([1]))", 102);
159+
160+
[TestMethod]
161+
public void Pivot_UnknownForColumn_Msg207()
162+
=> _ = new Simulation().AssertSqlError(
163+
"create table t (a int, b int, c int); " +
164+
"select * from (select a, b, c from t) s pivot (sum(c) for nope in ([1])) p", 207);
165+
166+
[TestMethod]
167+
public void Pivot_DuplicateInValue_Msg8156()
168+
{
169+
var ex = new Simulation().AssertSqlError(
170+
"create table t (a int, b int, c int); " +
171+
"select * from (select a, b, c from t) s pivot (sum(c) for b in ([1],[1])) p", 8156);
172+
Contains("was specified multiple times for 'p'", ex.Message);
173+
}
174+
175+
// === UNPIVOT ===
176+
177+
private static DbConnection SeededQuarters()
178+
{
179+
var connection = new Simulation().CreateOpenConnection();
180+
_ = connection.CreateCommand(
181+
"create table q (ProductId int, Q1 int, Q2 int, Q3 int, Q4 int)").ExecuteNonQuery();
182+
_ = connection.CreateCommand(
183+
"insert q values (1, 10, 20, null, 40), (2, null, null, null, null), (3, 5, 6, 7, 8)").ExecuteNonQuery();
184+
return connection;
185+
}
186+
187+
[TestMethod]
188+
public void Unpivot_ExcludesNullsAndAllNullRows()
189+
{
190+
using var connection = SeededQuarters();
191+
using var reader = connection.CreateCommand(
192+
"select ProductId, Quarter, Sales from q " +
193+
"unpivot (Sales for Quarter in (Q1, Q2, Q3, Q4)) as u " +
194+
"order by ProductId, Quarter").ExecuteReader();
195+
var rows = new List<(int, string, int)>();
196+
while (reader.Read())
197+
rows.Add((reader.GetInt32(0), reader.GetString(1), reader.GetInt32(2)));
198+
// Product 2 is all-NULL (vanishes); Product 1 drops its Q3 NULL.
199+
CollectionAssert.AreEqual(
200+
new[]
201+
{
202+
(1, "Q1", 10), (1, "Q2", 20), (1, "Q4", 40),
203+
(3, "Q1", 5), (3, "Q2", 6), (3, "Q3", 7), (3, "Q4", 8),
204+
},
205+
rows);
206+
}
207+
208+
[TestMethod]
209+
public void Unpivot_SelectStar_ValueThenNameColumn()
210+
{
211+
// SELECT * order: passthrough, then value column, then name column.
212+
using var connection = SeededQuarters();
213+
using var reader = connection.CreateCommand(
214+
"select * from q unpivot (Sales for Quarter in (Q1, Q2, Q3, Q4)) as u").ExecuteReader();
215+
AreEqual("ProductId", reader.GetName(0));
216+
AreEqual("Sales", reader.GetName(1));
217+
AreEqual("Quarter", reader.GetName(2));
218+
}
219+
220+
[TestMethod]
221+
public void Unpivot_TypeConflict_Msg8167()
222+
{
223+
var ex = new Simulation().AssertSqlError(
224+
"create table m (Id int, A bigint, B int); insert m values (1, 100, 2); " +
225+
"select Id, Col, Val from m unpivot (Val for Col in (A, B)) as u", 8167);
226+
Contains("conflicts with the type of other columns", ex.Message);
227+
}
228+
229+
[TestMethod]
230+
public void Unpivot_MissingAlias_Rejected()
231+
=> _ = new Simulation().AssertSqlError(
232+
"create table q (ProductId int, Q1 int, Q2 int); " +
233+
"select * from q unpivot (Sales for Quarter in (Q1, Q2))", 102);
234+
235+
[TestMethod]
236+
public void Unpivot_UnknownColumn_Msg207()
237+
=> _ = new Simulation().AssertSqlError(
238+
"create table q (ProductId int, Q1 int, Q2 int); " +
239+
"select * from q unpivot (Sales for Quarter in (Q1, Nope)) as u", 207);
240+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ internal static SimulatedSqlException SubqueryNotIntroducedWithExists() =>
4646
internal static SimulatedSqlException GroupingArgumentNotInGroupBy(int argumentIndex, string functionName = "GROUPING") =>
4747
new($"Argument {argumentIndex} of the {functionName} function does not match any of the expressions in the GROUP BY clause.", 8161, 16, 1);
4848

49+
/// <summary>
50+
/// Mimics SQL Server's Msg 8156 — raised when a PIVOT's <c>IN</c> list
51+
/// names the same pivot value twice (each value becomes an output column,
52+
/// so a repeat is a duplicate column). <paramref name="pivotAlias"/> is
53+
/// the alias given to the PIVOT table source.
54+
/// </summary>
55+
internal static SimulatedSqlException PivotColumnSpecifiedMultipleTimes(string column, string pivotAlias) =>
56+
new($"The column '{column}' was specified multiple times for '{pivotAlias}'.", 8156, 16, 1);
57+
58+
/// <summary>
59+
/// Mimics SQL Server's Msg 8167 — raised when the columns in an
60+
/// UNPIVOT's <c>IN</c> list don't all share one type (UNPIVOT folds them
61+
/// into a single value column, so their types must match). The
62+
/// conflicting column name is double-quoted in the message.
63+
/// </summary>
64+
internal static SimulatedSqlException UnpivotColumnTypeConflict(string column) =>
65+
new($"The type of column \"{column}\" conflicts with the type of other columns specified in the UNPIVOT list.", 8167, 16, 1);
66+
4967
/// <summary>
5068
/// Mimics SQL Server's Msg 512 — fired when a scalar subquery (or one
5169
/// behind a comparison operator) returns more than one row. Verbatim

SqlServerSimulator/Parser/Expressions/AggregateExpression.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ private AggregateExpression(AggregateKind kind, Expression? operand, bool distin
9898
_ => throw new InvalidOperationException($"Unknown aggregate kind {this.Kind}."),
9999
};
100100

101+
/// <summary>
102+
/// Builds a single-operand aggregate programmatically (used by PIVOT
103+
/// desugaring, where each pivot column becomes
104+
/// <c>&lt;kind&gt;(CASE forCol WHEN value THEN argCol END)</c>). Bypasses
105+
/// the token parser and the <c>AggregateCollector</c> registration — the
106+
/// PIVOT planner hands the built list straight to
107+
/// <c>Selection.BuildSqlProjection</c>.
108+
/// </summary>
109+
internal static AggregateExpression CreatePivotAggregate(AggregateKind kind, Expression operand) =>
110+
new(kind, operand, distinct: false, separator: null);
111+
101112
/// <summary>
102113
/// Convenience overload that auto-registers the new instance with the
103114
/// parser context's aggregate collector (when one is in scope, e.g.

SqlServerSimulator/Parser/Expressions/CaseExpression.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ private CaseExpression(Expression? input, BooleanExpression[]? searchedWhens, Ex
5656
this.elseBranch = elseBranch;
5757
}
5858

59+
/// <summary>
60+
/// Builds a simple-form CASE programmatically — <c>CASE input WHEN
61+
/// compareValues[i] THEN thens[i] ... [ELSE elseBranch] END</c> — bypassing
62+
/// the token parser. Used by PIVOT desugaring, where each pivot column is
63+
/// <c>&lt;agg&gt;(CASE forCol WHEN value THEN argCol END)</c>; the
64+
/// simple-form <c>=</c> comparison aligns the value's type to the FOR
65+
/// column's via <see cref="BooleanExpression.CompareValuesPromoted"/>.
66+
/// </summary>
67+
internal static CaseExpression CreateSimple(Expression input, Expression[] compareValues, Expression[] thens, Expression? elseBranch) =>
68+
new(input, searchedWhens: null, compareValues, thens, elseBranch);
69+
5970
public override SqlValue Run(RuntimeContext runtime)
6071
{
6172
var raw = this.input is null ? FindSearchedMatch(runtime) : FindSimpleMatch(runtime);

0 commit comments

Comments
 (0)