Skip to content

Commit cd7862c

Browse files
committed
GROUP BY ROLLUP / CUBE / GROUPING SETS + GROUPING() / GROUPING_ID(): probe-discovered gap — GROUP BY extensions were entirely unparsed (GROUP BY ROLLUP(...) surfaced as "rollup is not a recognized built-in function name" through the projection-list path). FromClause.GroupBy: List<Expression> refactored to FromClause.GroupingSets: List<Expression[]> + FromClause.AllGroupingExpressions: List<Expression>; simple GROUP BY a, b parses as the trivial single-element [[a, b]], the extensions desugar at parse time into multiple entries via Cartesian product across all top-level GROUP BY items. ParseGroupByItem dispatches ROLLUP (N+1 shrinking-prefix fragments), CUBE (2^N popcount-iterated subsets), and GROUPING SETS((set), (set), ...) (verbatim member list including the empty-paren grand-total form). ROLLUP / CUBE / GROUPING / GROUPING_ID / SETS land in ContextualKeyword (not on the canonical T-SQL reserved list); parser sites read them via UnquotedString.ContextualKeyword, identifier positions stay unaffected. Selection.Execution.Aggregate.cs unified around buffer-once / iterate-per-set: the WHERE-passing-row buffer snapshots each tuple because EnumerateJoinedRows reuses a single in-place array; the outer loop iterates effectiveSets (synthesized [[]] when GROUP BY is absent — covers implicit-empty-group case and explicit GROUPING SETS(()) through the same code path), partitions the buffer per set's columns, accumulates fresh aggregators per group, projects with a grouped-away NULL resolver. New BatchContext.GroupingSetExpressions / AllGroupingExpressions published by the executor around each group's projection let new Grouping / GroupingId expression classes read current-set context. GROUPING(col) returns tinyint 0 when col is in the current set or 1 when it's grouped-away; GROUPING_ID(c1, ..., cN) returns int bitmap where the leftmost argument occupies the most-significant bit (probe-confirmed: GROUPING_ID(region, product) with region grouped + product not grouped → 2; the inverse → 1). Argument matching is column-leaf-name on Reference args (non-Reference args raise NotSupportedException). Arg not in any grouping set or GROUPING outside any GROUP BY context raises Msg 8161 via new QueryErrors.GroupingArgumentNotInGroupBy factory. 12 new GroupingSetTests cover ROLLUP N+1 sets, CUBE 2^N subsets, explicit GROUPING SETS, grand-total-only GROUPING SETS(()), GROUPING distinguishing real-NULL data rows from subtotal-NULL marker rows, GROUPING_ID bitmap orientation, mixed regular-column + ROLLUP Cartesian, HAVING + GROUPING filter, single-column ROLLUP, Msg 8161 paths (bare-SELECT + non-grouped arg), and a plain-GROUP-BY regression smoke against the refactor. New "GROUP BY extensions" section in docs/claude/query.md covers the parse-time desugar, buffer-once executor with the EnumerateJoinedRows aliasing hazard, and the GROUPING family's column-name matching.
1 parent 49e6f37 commit cd7862c

10 files changed

Lines changed: 744 additions & 81 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Tests for the GROUP BY extension grammar — <c>ROLLUP</c>, <c>CUBE</c>,
8+
/// <c>GROUPING SETS</c> — plus the <c>GROUPING()</c> / <c>GROUPING_ID()</c>
9+
/// scalars that distinguish subtotal/total-row NULLs from data NULLs.
10+
/// Probe-confirmed against SQL Server 2025 (2026-05-13).
11+
/// </summary>
12+
[TestClass]
13+
public sealed class GroupingSetTests
14+
{
15+
private static DbConnection SeededSales()
16+
{
17+
var conn = new Simulation().CreateOpenConnection();
18+
_ = conn.CreateCommand("""
19+
create table sales (region varchar(10), product varchar(10), amount int);
20+
insert sales values
21+
('east', 'widget', 100), ('east', 'widget', 150),
22+
('east', 'gadget', 50), ('east', null, 25),
23+
('west', 'widget', 200), ('west', 'gadget', 300)
24+
""").ExecuteNonQuery();
25+
return conn;
26+
}
27+
28+
private static List<(string? Region, string? Product, int Total)> ReadRegionProductTotal(DbConnection conn, string sql)
29+
{
30+
using var reader = conn.CreateCommand(sql).ExecuteReader();
31+
var rows = new List<(string?, string?, int)>();
32+
while (reader.Read())
33+
{
34+
var region = reader.IsDBNull(0) ? null : reader.GetString(0);
35+
var product = reader.IsDBNull(1) ? null : reader.GetString(1);
36+
var total = reader.GetInt32(2);
37+
rows.Add((region, product, total));
38+
}
39+
return rows;
40+
}
41+
42+
[TestMethod]
43+
public void Rollup_TwoColumns_NPlus1Sets()
44+
{
45+
// ROLLUP(region, product) generates sets: (region, product), (region), ().
46+
// Per-region subtotals + grand total in addition to the leaf rows.
47+
using var conn = SeededSales();
48+
var rows = ReadRegionProductTotal(conn,
49+
"select region, product, sum(amount) from sales group by rollup(region, product) order by region, product");
50+
// Order: region NULLS FIRST (varchar NULLs sort first under default).
51+
Assert.HasCount(8, rows);
52+
CollectionAssert.Contains(rows, ((string?)null, (string?)null, 825)); // Grand total
53+
CollectionAssert.Contains(rows, ((string?)"east", (string?)null, 25)); // east + null product (real data)
54+
CollectionAssert.Contains(rows, ((string?)"east", (string?)null, 325)); // east subtotal
55+
CollectionAssert.Contains(rows, ((string?)"east", (string?)"gadget", 50));
56+
CollectionAssert.Contains(rows, ((string?)"east", (string?)"widget", 250));
57+
CollectionAssert.Contains(rows, ((string?)"west", (string?)null, 500)); // west subtotal
58+
CollectionAssert.Contains(rows, ((string?)"west", (string?)"gadget", 300));
59+
CollectionAssert.Contains(rows, ((string?)"west", (string?)"widget", 200));
60+
}
61+
62+
[TestMethod]
63+
public void Cube_TwoColumns_AllSubsets()
64+
{
65+
// CUBE(region, product) generates 2^2 = 4 sets: (region, product),
66+
// (region), (product), (). Total rows = distinct (r, p) + distinct r
67+
// + distinct p + 1 grand total.
68+
using var conn = SeededSales();
69+
var rows = ReadRegionProductTotal(conn,
70+
"select region, product, sum(amount) from sales group by cube(region, product) order by region, product");
71+
Assert.HasCount(11, rows);
72+
// Includes per-product subtotals across all regions:
73+
CollectionAssert.Contains(rows, ((string?)null, (string?)"gadget", 350));
74+
CollectionAssert.Contains(rows, ((string?)null, (string?)"widget", 450));
75+
// The null-product across all regions (one real row in 'east'):
76+
CollectionAssert.Contains(rows, ((string?)null, (string?)null, 25));
77+
// Plus the grand total:
78+
CollectionAssert.Contains(rows, ((string?)null, (string?)null, 825));
79+
}
80+
81+
[TestMethod]
82+
public void GroupingSets_ExplicitList()
83+
{
84+
// GROUPING SETS((region, product), (region), ()) should match the
85+
// ROLLUP shape (which is exactly this set list).
86+
using var conn = SeededSales();
87+
var rows = ReadRegionProductTotal(conn,
88+
"select region, product, sum(amount) from sales group by grouping sets((region, product), (region), ()) order by region, product");
89+
Assert.HasCount(8, rows);
90+
}
91+
92+
[TestMethod]
93+
public void GroupingSetsEmpty_SingleGrandTotalRow()
94+
{
95+
// GROUPING SETS(()) is the grand-total-only form.
96+
using var conn = SeededSales();
97+
using var reader = conn.CreateCommand(
98+
"select sum(amount) from sales group by grouping sets(())").ExecuteReader();
99+
IsTrue(reader.Read());
100+
AreEqual(825, reader.GetInt32(0));
101+
IsFalse(reader.Read());
102+
}
103+
104+
[TestMethod]
105+
public void Grouping_DistinguishesSubtotalFromRealNull()
106+
{
107+
// Order by grouping(product), region — subtotals (grouping=1) come
108+
// after detail rows (grouping=0) for the product column.
109+
using var conn = SeededSales();
110+
using var reader = conn.CreateCommand("""
111+
select region, product, grouping(region) as gr, grouping(product) as gp, sum(amount) as total
112+
from sales group by rollup(region, product)
113+
order by 3, 4, 1, 2
114+
""").ExecuteReader();
115+
var rows = new List<(string? Region, string? Product, byte GR, byte GP, int Total)>();
116+
while (reader.Read())
117+
{
118+
rows.Add((
119+
reader.IsDBNull(0) ? null : reader.GetString(0),
120+
reader.IsDBNull(1) ? null : reader.GetString(1),
121+
reader.GetByte(2),
122+
reader.GetByte(3),
123+
reader.GetInt32(4)));
124+
}
125+
Assert.HasCount(8, rows);
126+
// First five rows are detail rows (gr=0, gp=0). The (east, null, 25)
127+
// is a real-NULL data row, NOT a subtotal — its grouping() flags are
128+
// both 0 (probe-confirmed: GROUPING distinguishes them).
129+
Assert.HasCount(5, rows.FindAll(r => r.GR == 0 && r.GP == 0));
130+
// Per-region subtotals: gp=1.
131+
Assert.HasCount(2, rows.FindAll(r => r.GR == 0 && r.GP == 1));
132+
// Grand total: gr=1, gp=1.
133+
Assert.HasCount(1, rows.FindAll(r => r.GR == 1 && r.GP == 1));
134+
}
135+
136+
[TestMethod]
137+
public void GroupingId_BitmapLeftmostIsMostSignificantBit()
138+
{
139+
// GROUPING_ID(region, product): region grouped → bit 1; product
140+
// grouped → bit 0. So bitmap values: detail rows = 0, per-region
141+
// (product grouped) = 1, per-product across regions = 2, grand
142+
// total = 3.
143+
using var conn = SeededSales();
144+
using var reader = conn.CreateCommand("""
145+
select grouping_id(region, product) as gid, sum(amount) as total
146+
from sales group by cube(region, product)
147+
order by 1
148+
""").ExecuteReader();
149+
var bitmaps = new List<int>();
150+
while (reader.Read())
151+
bitmaps.Add(reader.GetInt32(0));
152+
Assert.HasCount(11, bitmaps);
153+
// Probe-confirmed row counts: 5 detail rows (only 5 distinct
154+
// (region, product) combos exist in the data — west has no null-
155+
// product row), 2 per-region subtotals (east, west), 3 per-product
156+
// values across regions (null/gadget/widget), 1 grand total.
157+
Assert.HasCount(5, bitmaps.FindAll(b => b == 0));
158+
Assert.HasCount(2, bitmaps.FindAll(b => b == 1));
159+
Assert.HasCount(3, bitmaps.FindAll(b => b == 2));
160+
Assert.HasCount(1, bitmaps.FindAll(b => b == 3));
161+
}
162+
163+
[TestMethod]
164+
public void MixedGroupBy_RegularColumnPlusRollup_CartesianProduct()
165+
{
166+
// `GROUP BY region, ROLLUP(product)` — region is always grouped,
167+
// ROLLUP(product) contributes (product) and (). So sets:
168+
// (region, product), (region). 5 detail + 2 per-region = 7 rows.
169+
using var conn = SeededSales();
170+
var rows = ReadRegionProductTotal(conn,
171+
"select region, product, sum(amount) from sales group by region, rollup(product) order by region, product");
172+
Assert.HasCount(7, rows);
173+
CollectionAssert.Contains(rows, ((string?)"east", (string?)null, 25)); // real data row (east, null product)
174+
CollectionAssert.Contains(rows, ((string?)"east", (string?)null, 325)); // east subtotal across products
175+
CollectionAssert.Contains(rows, ((string?)"west", (string?)null, 500)); // west subtotal
176+
}
177+
178+
[TestMethod]
179+
public void HavingWithGroupingFilter()
180+
{
181+
// GROUPING(region) = 0 filters to detail (non-grand-total) rows.
182+
using var conn = SeededSales();
183+
using var reader = conn.CreateCommand("""
184+
select region, sum(amount) as total
185+
from sales group by rollup(region)
186+
having grouping(region) = 0
187+
order by region
188+
""").ExecuteReader();
189+
var rows = new List<(string Region, int Total)>();
190+
while (reader.Read())
191+
rows.Add((reader.GetString(0), reader.GetInt32(1)));
192+
Assert.HasCount(2, rows);
193+
AreEqual(("east", 325), rows[0]);
194+
AreEqual(("west", 500), rows[1]);
195+
}
196+
197+
[TestMethod]
198+
public void SingleColumnRollup_TwoGroupingSets()
199+
{
200+
// ROLLUP(region) → (region), (). Per-region totals + grand total.
201+
using var conn = SeededSales();
202+
using var reader = conn.CreateCommand(
203+
"select region, sum(amount) from sales group by rollup(region) order by region").ExecuteReader();
204+
var rows = new List<(string? Region, int Total)>();
205+
while (reader.Read())
206+
rows.Add((reader.IsDBNull(0) ? null : reader.GetString(0), reader.GetInt32(1)));
207+
Assert.HasCount(3, rows);
208+
CollectionAssert.Contains(rows, ((string?)null, 825));
209+
CollectionAssert.Contains(rows, ((string?)"east", 325));
210+
CollectionAssert.Contains(rows, ((string?)"west", 500));
211+
}
212+
213+
[TestMethod]
214+
public void GroupingOutsideGroupBy_RaisesMsg8161()
215+
{
216+
// Probe-confirmed: GROUPING() outside any GROUP BY context raises
217+
// Msg 8161 (same as when arg isn't in GROUP BY).
218+
using var conn = SeededSales();
219+
using var cmd = conn.CreateCommand("select grouping(region) from sales");
220+
var ex = Throws<DbException>(() => cmd.ExecuteReader().Read());
221+
AreEqual("8161", ex.Data["HelpLink.EvtID"]);
222+
AreEqual(
223+
"Argument 1 of the GROUPING function does not match any of the expressions in the GROUP BY clause.",
224+
ex.Message);
225+
}
226+
227+
[TestMethod]
228+
public void GroupingOfNonGroupedColumn_RaisesMsg8161()
229+
{
230+
// Probe-confirmed: GROUPING(product) when GROUP BY only lists region
231+
// raises Msg 8161 — the arg must match a GROUP BY expression.
232+
using var conn = SeededSales();
233+
using var cmd = conn.CreateCommand(
234+
"select region, grouping(product) from sales group by region");
235+
var ex = Throws<DbException>(() => cmd.ExecuteReader().Read());
236+
AreEqual("8161", ex.Data["HelpLink.EvtID"]);
237+
}
238+
239+
[TestMethod]
240+
public void SimpleGroupBy_StillWorksAfterRefactor()
241+
{
242+
// Regression: the FromClause.GroupBy → GroupingSets refactor must
243+
// preserve plain GROUP BY semantics. Smoke test.
244+
using var conn = SeededSales();
245+
using var reader = conn.CreateCommand(
246+
"select region, sum(amount) from sales group by region order by region").ExecuteReader();
247+
var rows = new List<(string Region, int Total)>();
248+
while (reader.Read())
249+
rows.Add((reader.GetString(0), reader.GetInt32(1)));
250+
Assert.HasCount(2, rows);
251+
AreEqual(("east", 325), rows[0]);
252+
AreEqual(("west", 500), rows[1]);
253+
}
254+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ internal static SimulatedSqlException PerBranchOrderByRejected(string setOpKeywo
3535
internal static SimulatedSqlException SubqueryNotIntroducedWithExists() =>
3636
new("Only one expression can be specified in the select list when the subquery is not introduced with EXISTS.", 116, 16, 1);
3737

38+
/// <summary>
39+
/// Mimics SQL Server's Msg 8161 — raised when an argument to
40+
/// <c>GROUPING()</c> or <c>GROUPING_ID()</c> doesn't match any
41+
/// expression in the surrounding query's GROUP BY clause (or when the
42+
/// function is used outside a GROUP BY context entirely).
43+
/// </summary>
44+
internal static SimulatedSqlException GroupingArgumentNotInGroupBy(int argumentIndex) =>
45+
new($"Argument {argumentIndex} of the GROUPING function does not match any of the expressions in the GROUP BY clause.", 8161, 16, 1);
46+
3847
/// <summary>
3948
/// Mimics SQL Server's Msg 512 — fired when a scalar subquery (or one
4049
/// behind a comparison operator) returns more than one row. Verbatim

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,26 @@ internal sealed class BatchContext
221221
/// </summary>
222222
public TriggerFrame? TriggerFrame;
223223

224+
/// <summary>
225+
/// Current grouping-set context — populated by the aggregate executor
226+
/// during projection of each group, restored to null between groups and
227+
/// between queries. Non-null surface exposes GROUPING() / GROUPING_ID()
228+
/// to the projection / HAVING expressions: <see cref="GroupingSetExpressions"/>
229+
/// is the set's column list (what's *not* grouped away for this row);
230+
/// <see cref="AllGroupingExpressions"/> is the union across all sets in
231+
/// the query (used to detect GROUPING(arg) where arg isn't in any
232+
/// grouping set — Msg 8161). Null outside an aggregate query — bare
233+
/// <c>SELECT GROUPING(x) FROM t</c> raises Msg 8161 via this null check.
234+
/// </summary>
235+
public Expression[]? GroupingSetExpressions;
236+
237+
/// <summary>
238+
/// Companion to <see cref="GroupingSetExpressions"/> — the union of all
239+
/// grouping-set columns across the query. See that field's docs for the
240+
/// pair's role in GROUPING() validation.
241+
/// </summary>
242+
public IReadOnlyList<Expression>? AllGroupingExpressions;
243+
224244
public bool IsSkipping =>
225245
this.SkipModeFlag
226246
|| this.LoopControl != LoopControl.None

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,16 @@ enum ContextualKeyword
2929
Catch,
3030
Compatibility_Level,
3131
Configuration,
32+
Cube,
3233
Cycle,
3334
Delay,
3435
Disable,
3536
Enable,
3637
Encryption,
3738
First,
3839
Following,
40+
Grouping_Id,
41+
Grouping,
3942
Increment,
4043
Input,
4144
Instead,
@@ -62,11 +65,13 @@ enum ContextualKeyword
6265
Replication,
6366
Restart,
6467
Returns,
68+
Rollup,
6569
Row,
6670
Rows,
6771
Schemabinding,
6872
Scoped,
6973
Sequence,
74+
Sets,
7075
Target,
7176
SetError,
7277
Source,

SqlServerSimulator/Parser/Expression.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
503503
"COALESCE" => new Coalesce(context),
504504
"DATEDIFF" => new DateDiff.Standard(context),
505505
"DATEPART" => new DatePart(context),
506+
"GROUPING" => new Grouping(context),
506507
"PATINDEX" => new PatIndex(context),
507508
"TRY_CAST" => new Cast(context, tryMode: true),
508509
_ => null
@@ -536,6 +537,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
536537
{
537538
"ERROR_STATE" => new ErrorStateFunction(context),
538539
"FIRST_VALUE" => WindowExpression.ParseFirstValue(context),
540+
"GROUPING_ID" => new GroupingId(context),
539541
"JSON_MODIFY" => new JsonModify(context),
540542
"OBJECT_NAME" => new ObjectName(context),
541543
"SCHEMA_NAME" => new SchemaName(context),

0 commit comments

Comments
 (0)