Skip to content

Commit 66db17b

Browse files
committed
Implemented aggregators.
1 parent 5a1ff4e commit 66db17b

17 files changed

Lines changed: 1455 additions & 26 deletions

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ Prefer public-API tests when the behavior is reachable from SQL — they exercis
7272
Heavy-hitters someone might assume work but don't. Source and `git log` are the truth for what's done; this list is for what isn't.
7373

7474
- Transactions / locks / MVCC.
75-
- `text` / `ntext` / `image` operation restrictions: comparison (Msg 402) and ORDER BY / DISTINCT (Msg 306) are enforced; function-level restrictions (e.g. `LEN(ntext)` raising Msg 8116) and the legacy `READTEXT`/`WRITETEXT`/`UPDATETEXT` family aren't modeled.
75+
- `text` / `ntext` / `image` operation restrictions: comparison (Msg 402), ORDER BY / DISTINCT (Msg 306), and aggregates (Msg 8117 from MAX/MIN) are enforced; function-level restrictions (e.g. `LEN(ntext)` raising Msg 8116) and the legacy `READTEXT`/`WRITETEXT`/`UPDATETEXT` family aren't modeled.
76+
- Aggregate functions: `COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (with the documented integer-truncating behavior and `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, the statistical family (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, and `APPROX_COUNT_DISTINCT` all parse and execute, both standalone and inside `GROUP BY` / `HAVING`. Window-function form (`OVER (...)`) and `STRING_AGG`'s `WITHIN GROUP (ORDER BY ...)` ordering aren't parsed yet. `CHECKSUM_AGG` returns an order-independent XOR fold whose semantic guarantee matches SQL Server (same multiset → same checksum) but whose exact bit pattern won't byte-match a real-server reproduction. `APPROX_COUNT_DISTINCT` is implemented as exact `COUNT(DISTINCT)` since memory optimization isn't a goal here.
7677
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout.
7778
- `CONVERT` / `TRY_CONVERT` style-code coverage. Only styles `0`, `120`, and `121` are wired up for date-like sources targeting a character string (the EF Core code-generation defaults). Other style numbers raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date-parsing styles aren't modeled.
7879
- Boolean combinators in WHERE / MERGE-ON / CHECK predicates: `AND` / `OR` / `NOT`, parenthesized groupings, `IS [NOT] NULL`, and `[NOT] IN (literal, literal, ...)` all parse with standard SQL precedence (AND > OR; NOT highest). The pattern `where (arith_expr) cmp rhs` (parens-around-arithmetic as the left side of a comparison) is the one shape the simulator's parser doesn't accept; SQL Server does. `IN` accepts only an expression list, not a subquery (`IN (SELECT ...)`). `BooleanExpression.Run` returns `bool?` (three-valued: true / false / UNKNOWN); WHERE / MERGE-ON treat UNKNOWN as exclude, CHECK treats UNKNOWN as pass — matching SQL Server. `IS NULL` definitively resolves UNKNOWN to true/false (it's the canonical way to test nullability without falling through tri-state).
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// End-to-end tests for EF Core's LINQ aggregate translations: <c>.Count()</c>,
5+
/// <c>.Sum()</c>, <c>.Max()</c>, <c>.Min()</c>, <c>.Average()</c>, plus
6+
/// <c>.GroupBy(...).Select(...)</c> projections that exercise the
7+
/// simulator's GROUP BY / HAVING path. Pre-aggregate-support these all
8+
/// failed with "COUNT not a recognized built-in function".
9+
/// </summary>
10+
[TestClass]
11+
public class EFCoreAggregates
12+
{
13+
public TestContext TestContext { get; set; } = null!;
14+
15+
private static TestDbContext SeededContext()
16+
{
17+
var context = new TestDbContext(TestDbContext.CreateFiltersSimulation());
18+
context.Filters.AddRange(
19+
new Filter { A = 1, B = 1, NullableC = 10, IsActive = true, Status = "active" },
20+
new Filter { A = 1, B = 2, NullableC = null, IsActive = false, Status = "pending" },
21+
new Filter { A = 2, B = 2, NullableC = 20, IsActive = true, Status = "active" },
22+
new Filter { A = 2, B = 3, NullableC = null, IsActive = false, Status = null },
23+
new Filter { A = 3, B = 1, NullableC = 30, IsActive = true, Status = "archived" });
24+
_ = context.SaveChanges();
25+
return context;
26+
}
27+
28+
[TestMethod]
29+
public void Count_OverWholeTable()
30+
{
31+
using var context = SeededContext();
32+
Assert.AreEqual(5, context.Filters.Count());
33+
}
34+
35+
[TestMethod]
36+
public void Count_WithPredicate()
37+
{
38+
using var context = SeededContext();
39+
Assert.AreEqual(3, context.Filters.Count(f => f.IsActive));
40+
}
41+
42+
[TestMethod]
43+
public void Sum_OverInt()
44+
{
45+
using var context = SeededContext();
46+
Assert.AreEqual(9, context.Filters.Sum(f => f.A));
47+
}
48+
49+
[TestMethod]
50+
public void Max_OverInt()
51+
{
52+
using var context = SeededContext();
53+
Assert.AreEqual(3, context.Filters.Max(f => f.A));
54+
}
55+
56+
[TestMethod]
57+
public void Min_OverInt()
58+
{
59+
using var context = SeededContext();
60+
Assert.AreEqual(1, context.Filters.Min(f => f.A));
61+
}
62+
63+
[TestMethod]
64+
public void Average_OverInt()
65+
{
66+
// EF Core casts to float in the SQL emit so .Average() returns the
67+
// mathematical mean (1.8 for sum=9, count=5) rather than SQL Server's
68+
// truncating int AVG (which would return 1).
69+
using var context = SeededContext();
70+
Assert.AreEqual(1.8d, context.Filters.Average(f => f.A));
71+
}
72+
73+
[TestMethod]
74+
public void GroupBy_CountPerKey()
75+
{
76+
// EF Core: .GroupBy(s).Select(g => new { Key, Count }).
77+
using var context = SeededContext();
78+
var byStatus = context.Filters
79+
.GroupBy(f => f.Status)
80+
.Select(g => new { Status = g.Key, Count = g.Count() })
81+
.ToArray();
82+
83+
var statusCounts = byStatus.ToDictionary(x => x.Status ?? "<null>", x => x.Count);
84+
Assert.AreEqual(2, statusCounts["active"]);
85+
Assert.AreEqual(1, statusCounts["pending"]);
86+
Assert.AreEqual(1, statusCounts["archived"]);
87+
Assert.AreEqual(1, statusCounts["<null>"]);
88+
}
89+
90+
[TestMethod]
91+
public void GroupBy_SumPerKey()
92+
{
93+
using var context = SeededContext();
94+
var byA = context.Filters
95+
.GroupBy(f => f.A)
96+
.Select(g => new { A = g.Key, Total = g.Sum(f => f.B) })
97+
.ToArray();
98+
99+
var aTotals = byA.ToDictionary(x => x.A, x => x.Total);
100+
Assert.AreEqual(3, aTotals[1]); // 1 + 2
101+
Assert.AreEqual(5, aTotals[2]); // 2 + 3
102+
Assert.AreEqual(1, aTotals[3]);
103+
}
104+
105+
[TestMethod]
106+
public void Aggregate_OnEmptyFilteredSet()
107+
{
108+
// Sum / Max / Min over empty input → null in SQL; EF Core's int
109+
// aggregates throw or return 0 depending on the call shape. Use
110+
// .Where(...).Sum(...) which returns 0 for empty (the no-op
111+
// identity).
112+
using var context = SeededContext();
113+
Assert.AreEqual(0, context.Filters.Where(f => f.A > 999).Sum(f => f.A));
114+
}
115+
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for SQL aggregate functions: COUNT/COUNT_BIG/SUM/AVG/
8+
/// MAX/MIN, the statistical family (STDEV, STDEVP, VAR, VARP),
9+
/// STRING_AGG, CHECKSUM_AGG, APPROX_COUNT_DISTINCT — both standalone and
10+
/// in combination with GROUP BY / HAVING. Result types and NULL / empty-
11+
/// input semantics are sourced from probes against SQL Server 2025.
12+
/// </summary>
13+
[TestClass]
14+
public sealed class AggregateTests
15+
{
16+
private static DbConnection Seeded(string schema, string values)
17+
{
18+
var connection = new Simulation().CreateOpenConnection();
19+
_ = connection.CreateCommand($"create table t ({schema})").ExecuteNonQuery();
20+
if (!string.IsNullOrEmpty(values))
21+
_ = connection.CreateCommand($"insert into t values {values}").ExecuteNonQuery();
22+
return connection;
23+
}
24+
25+
[TestMethod]
26+
public void Count_Star_CountsRowsIncludingNullColumns()
27+
{
28+
using var connection = Seeded("a int", "(1), (2), (null), (3)");
29+
AreEqual(4, connection.CreateCommand("select count(*) from t").ExecuteScalar());
30+
}
31+
32+
[TestMethod]
33+
public void Count_Column_SkipsNulls()
34+
{
35+
using var connection = Seeded("a int", "(1), (2), (null), (3)");
36+
AreEqual(3, connection.CreateCommand("select count(a) from t").ExecuteScalar());
37+
}
38+
39+
[TestMethod]
40+
public void Count_Distinct_DedupsAndSkipsNulls()
41+
{
42+
using var connection = Seeded("a int", "(1), (2), (1), (null), (2)");
43+
AreEqual(2, connection.CreateCommand("select count(distinct a) from t").ExecuteScalar());
44+
}
45+
46+
[TestMethod]
47+
public void Count_EmptyInput_ReturnsZero()
48+
{
49+
// Only aggregate that doesn't return NULL on empty input.
50+
using var connection = Seeded("a int", "");
51+
AreEqual(0, connection.CreateCommand("select count(*) from t").ExecuteScalar());
52+
}
53+
54+
[TestMethod]
55+
public void CountBig_StarAlias_ReturnsBigInt()
56+
{
57+
using var connection = Seeded("a int", "(1), (2), (3)");
58+
AreEqual(3L, connection.CreateCommand("select count_big(*) from t").ExecuteScalar());
59+
}
60+
61+
[TestMethod]
62+
public void Sum_Int_TracksTotalSkippingNulls()
63+
{
64+
using var connection = Seeded("a int", "(10), (20), (null), (30)");
65+
AreEqual(60, connection.CreateCommand("select sum(a) from t").ExecuteScalar());
66+
}
67+
68+
[TestMethod]
69+
public void Sum_Decimal_PreservesScale()
70+
{
71+
// SQL Server: SUM(decimal(p, s)) → decimal(38, s). Probed against
72+
// SQL Server 2025 — scale 2 stays 2.
73+
using var connection = Seeded("p decimal(10, 2)", "(1.50), (2.50), (3.00)");
74+
AreEqual(7.00m, connection.CreateCommand("select sum(p) from t").ExecuteScalar());
75+
}
76+
77+
[TestMethod]
78+
public void Sum_EmptyInput_ReturnsNull()
79+
{
80+
using var connection = Seeded("a int", "");
81+
AreEqual(DBNull.Value, connection.CreateCommand("select sum(a) from t").ExecuteScalar());
82+
}
83+
84+
[TestMethod]
85+
public void Sum_AllNullInput_ReturnsNull()
86+
{
87+
using var connection = Seeded("a int", "(null), (null)");
88+
AreEqual(DBNull.Value, connection.CreateCommand("select sum(a) from t").ExecuteScalar());
89+
}
90+
91+
[TestMethod]
92+
public void Sum_IntOverflow_RaisesMsg8115()
93+
{
94+
using var connection = Seeded("a int", "(2147483647), (1)");
95+
var ex = Throws<DbException>(() => connection.CreateCommand("select sum(a) from t").ExecuteScalar());
96+
AreEqual("8115", ex.Data["HelpLink.EvtID"]);
97+
}
98+
99+
[TestMethod]
100+
public void Avg_Int_TruncatesByIntegerDivision()
101+
{
102+
// SQL Server: `avg(int)` returns int via integer division. (1+2+2)/3 = 1.
103+
using var connection = Seeded("a int", "(1), (2), (2)");
104+
AreEqual(1, connection.CreateCommand("select avg(a) from t").ExecuteScalar());
105+
}
106+
107+
[TestMethod]
108+
public void Avg_Decimal_WidensToDecimal38_6()
109+
{
110+
// SQL Server: avg(decimal(p, s)) → decimal(38, max(s, 6)). Scale 2
111+
// input → scale 6 output.
112+
using var connection = Seeded("p decimal(10, 2)", "(1.50), (2.50), (3.00)");
113+
var result = connection.CreateCommand("select avg(p) from t").ExecuteScalar();
114+
AreEqual(2.333333m, result);
115+
}
116+
117+
[TestMethod]
118+
public void Max_OnInt()
119+
{
120+
using var connection = Seeded("a int", "(10), (5), (20), (null)");
121+
AreEqual(20, connection.CreateCommand("select max(a) from t").ExecuteScalar());
122+
}
123+
124+
[TestMethod]
125+
public void Min_OnInt()
126+
{
127+
using var connection = Seeded("a int", "(10), (5), (20), (null)");
128+
AreEqual(5, connection.CreateCommand("select min(a) from t").ExecuteScalar());
129+
}
130+
131+
[TestMethod]
132+
public void MaxMin_OnString_ByCollationOrder()
133+
{
134+
using var connection = Seeded("s nvarchar(20)", "('alpha'), ('gamma'), ('beta')");
135+
AreEqual("gamma", connection.CreateCommand("select max(s) from t").ExecuteScalar());
136+
AreEqual("alpha", connection.CreateCommand("select min(s) from t").ExecuteScalar());
137+
}
138+
139+
[TestMethod]
140+
public void MaxMin_EmptyInput_ReturnsNull()
141+
{
142+
using var connection = Seeded("a int", "");
143+
AreEqual(DBNull.Value, connection.CreateCommand("select max(a) from t").ExecuteScalar());
144+
AreEqual(DBNull.Value, connection.CreateCommand("select min(a) from t").ExecuteScalar());
145+
}
146+
147+
[TestMethod]
148+
public void Max_OnText_RaisesMsg8117()
149+
{
150+
// SQL Server's Msg 8117: LOB types can't participate in MAX/MIN.
151+
using var connection = Seeded("t text", "('x')");
152+
var ex = Throws<DbException>(() => connection.CreateCommand("select max(t) from t").ExecuteScalar());
153+
AreEqual("8117", ex.Data["HelpLink.EvtID"]);
154+
}
155+
156+
[TestMethod]
157+
public void Stdev_SingleRow_ReturnsNull()
158+
{
159+
// Sample stddev needs n > 1. Single row → divide by zero → NULL.
160+
using var connection = Seeded("a int", "(5)");
161+
AreEqual(DBNull.Value, connection.CreateCommand("select stdev(a) from t").ExecuteScalar());
162+
}
163+
164+
[TestMethod]
165+
public void StdevP_SingleRow_ReturnsZero()
166+
{
167+
// Population stddev with n=1 has zero deviation.
168+
using var connection = Seeded("a int", "(5)");
169+
AreEqual(0d, connection.CreateCommand("select stdevp(a) from t").ExecuteScalar());
170+
}
171+
172+
[TestMethod]
173+
public void Var_VarP_OverIntegerColumn()
174+
{
175+
// 10, 20, 30: mean=20, sample var = ((10-20)^2 + 0 + (30-20)^2) / 2 = 100.
176+
// Population var = 200/3 ≈ 66.67.
177+
using var connection = Seeded("a int", "(10), (20), (30)");
178+
AreEqual(100d, connection.CreateCommand("select var(a) from t").ExecuteScalar());
179+
var pop = (double)connection.CreateCommand("select varp(a) from t").ExecuteScalar()!;
180+
IsLessThan(1e-5, Math.Abs(pop - 66.6666666));
181+
}
182+
183+
[TestMethod]
184+
public void StringAgg_ConcatsWithSeparator()
185+
{
186+
using var connection = Seeded("s nvarchar(20)", "('a'), ('b'), ('c')");
187+
AreEqual("a,b,c", connection.CreateCommand("select string_agg(s, ',') from t").ExecuteScalar());
188+
}
189+
190+
[TestMethod]
191+
public void StringAgg_SkipsNulls()
192+
{
193+
using var connection = Seeded("s nvarchar(20)", "('a'), (null), ('b')");
194+
AreEqual("a,b", connection.CreateCommand("select string_agg(s, ',') from t").ExecuteScalar());
195+
}
196+
197+
[TestMethod]
198+
public void StringAgg_EmptyInput_ReturnsNull()
199+
{
200+
using var connection = Seeded("s nvarchar(20)", "");
201+
AreEqual(DBNull.Value, connection.CreateCommand("select string_agg(s, ',') from t").ExecuteScalar());
202+
}
203+
204+
[TestMethod]
205+
public void ChecksumAgg_OrderIndependentFold()
206+
{
207+
// CHECKSUM_AGG's semantic guarantee is order-independence; the bit
208+
// pattern itself isn't pinned (SQL Server's exact algorithm is
209+
// implementation-defined). The simulator's contract: same multiset
210+
// of inputs → same checksum.
211+
using var ascending = Seeded("a int", "(1), (2), (3)");
212+
using var reversed = Seeded("a int", "(3), (2), (1)");
213+
AreEqual(
214+
ascending.CreateCommand("select checksum_agg(a) from t").ExecuteScalar(),
215+
reversed.CreateCommand("select checksum_agg(a) from t").ExecuteScalar());
216+
}
217+
218+
[TestMethod]
219+
public void ApproxCountDistinct_BehavesLikeCountDistinct()
220+
{
221+
// Simulator implements APPROX_COUNT_DISTINCT as exact COUNT(DISTINCT)
222+
// since memory optimization isn't a goal. Still returns bigint.
223+
using var connection = Seeded("a int", "(1), (2), (1), (null), (3)");
224+
AreEqual(3L, connection.CreateCommand("select approx_count_distinct(a) from t").ExecuteScalar());
225+
}
226+
227+
[TestMethod]
228+
public void GroupBy_PartitionsByKey()
229+
{
230+
using var connection = Seeded("s nvarchar(20), a int", "('alpha', 1), ('alpha', 2), ('beta', 5), ('beta', 7)");
231+
using var reader = connection.CreateCommand("select s, sum(a) from t group by s").ExecuteReader();
232+
var totals = new Dictionary<string, int>();
233+
while (reader.Read())
234+
totals[(string)reader[0]] = (int)reader[1];
235+
AreEqual(3, totals["alpha"]);
236+
AreEqual(12, totals["beta"]);
237+
}
238+
239+
[TestMethod]
240+
public void GroupBy_NullKey_OneBucketForNulls()
241+
{
242+
// SQL Server: NULL is a valid group key with exactly one bucket.
243+
using var connection = Seeded("a int, b int", "(null, 1), (null, 2), (1, 5)");
244+
using var reader = connection.CreateCommand("select a, sum(b) from t group by a").ExecuteReader();
245+
var seen = new List<(object key, int sum)>();
246+
while (reader.Read())
247+
seen.Add((reader[0], (int)reader[1]));
248+
HasCount(2, seen);
249+
}
250+
251+
[TestMethod]
252+
public void GroupBy_Having_FiltersByAggregatePredicate()
253+
{
254+
using var connection = Seeded("s nvarchar(20)", "('alpha'), ('alpha'), ('beta'), ('gamma')");
255+
using var reader = connection.CreateCommand("select s, count(*) from t group by s having count(*) > 1").ExecuteReader();
256+
var rows = 0;
257+
while (reader.Read())
258+
{
259+
AreEqual("alpha", reader[0]);
260+
AreEqual(2, reader[1]);
261+
rows++;
262+
}
263+
AreEqual(1, rows);
264+
}
265+
}

0 commit comments

Comments
 (0)