|
| 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 | +} |
0 commit comments