Skip to content

Commit efa5ac9

Browse files
committed
Window function expansion: RANK / DENSE_RANK / NTILE / LAG / LEAD / FIRST_VALUE end-to-end. Parser dispatch in Expression.cs (6 new entries by uppercase-name-length); WindowKind enum extended with nullable Operand / OffsetArg / DefaultArg / BucketCount fields on WindowExpression; six parser methods sharing ParseNoArgRankingFunction (RANK / DENSE_RANK / ROW_NUMBER) and ParseLagLead (LAG / LEAD) backbones, all ORDER-BY-required and RejectFrameSpec-rejected. Executor in Selection.Execution.Window.cs gains a kind-keyed switch: RANK skips on ties via "prior key matches → keep prior rank, else rank = i+1"; DENSE_RANK same walk without gaps; NTILE distributes via "first count % n buckets carry one extra row" (non-positive bucket count → new Msg 9819); LAG/LEAD share sign-flipped logic, default expression evaluated in current-row resolver context, offset evaluated once per query via new EvaluateScalarArg helper; FIRST_VALUE evaluates operand at indices[0] and broadcasts. LAST_VALUE deferred — its implicit-frame semantic returns the current row, not partition-last, so a faithful implementation surprises callers; the prerequisite is explicit-frame support (own bundle). 15 new WindowFunctionTests cases cover all five families plus tie behavior, partition boundaries, and the NTILE error path. EF Core 10's DbFunctions doesn't expose any of these as extension methods — probed and confirmed — so no EFCore test added (only ROW_NUMBER reaches LINQ via Skip / Take); the new coverage helps applications using raw SQL through FromSqlInterpolated. CLAUDE.md "Not modeled" rewritten to call out LAST_VALUE + explicit frames specifically; docs/claude/query.md expanded with the ranking / value / aggregate breakdown and the EF reach note.
1 parent 0e8640b commit efa5ac9

7 files changed

Lines changed: 683 additions & 56 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
157157
- Comma-separated FROM (legacy ANSI-89 join syntax).
158158
- `ANY` / `SOME` / `ALL` quantifiers.
159159
- Row-constructor `IN ((1,2), (3,4))`.
160-
- Window functions other than `ROW_NUMBER` and the aggregate-OVER family.
160+
- `LAST_VALUE` window function — implicit-frame semantics return the current row (or last-of-ties under RANGE), not the partition's last value; the intuitive "partition-last" form requires explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`, which `RejectFrameSpec` rejects. Frame-spec support is the prerequisite.
161+
- Explicit window frames (`ROWS BETWEEN` / `RANGE BETWEEN`) — `RejectFrameSpec` raises `NotSupportedException`. The implicit defaults that ROW_NUMBER / RANK / DENSE_RANK / NTILE / LAG / LEAD / FIRST_VALUE / aggregate-OVER use natively are sufficient for EF Core's emit shape; LINQ doesn't reach any of the ranking/value functions other than ROW_NUMBER (via `Skip`/`Take`) and aggregate-OVER, so the simulator's window functions beyond ROW_NUMBER are reachable only via raw SQL.
161162
- Recursive-part feature restrictions (Msg 460 DISTINCT / 461 TOP / 462 OUTER JOIN / 467 aggregate-or-GROUP-BY / 465 ref-in-subquery) — silently accepted with possibly-incorrect semantics rather than raising. Apps that exercise these in real SQL Server hit rejection there too.
162163
- `LIKE` with `COLLATE` override (default collation only).
163164
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.

SqlServerSimulator.Tests/WindowFunctionTests.cs

Lines changed: 250 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
namespace SqlServerSimulator;
55

66
/// <summary>
7-
/// Behavioral tests for <c>ROW_NUMBER() OVER(PARTITION BY ... ORDER BY ...)</c>,
8-
/// the single shape EF Core 10 emits for <c>Take</c>-per-group / <c>Skip+Take</c>-per-group
9-
/// patterns. The function's value depends on the entire row stream
10-
/// (sorted within partition), so the executor buffers post-WHERE tuples
11-
/// before binding per-row results.
7+
/// Behavioral tests for ranking windows (<c>ROW_NUMBER</c> / <c>RANK</c> /
8+
/// <c>DENSE_RANK</c> / <c>NTILE</c>) and value windows (<c>LAG</c> /
9+
/// <c>LEAD</c> / <c>FIRST_VALUE</c>). All require ORDER BY inside OVER and
10+
/// share the post-WHERE buffer + per-partition sort path; the ranking
11+
/// family additionally tracks tie behavior (RANK skips, DENSE_RANK doesn't,
12+
/// ROW_NUMBER assigns arbitrary order within ties), and value functions
13+
/// re-resolve the operand against another row's tuple. <c>LAST_VALUE</c>
14+
/// isn't covered — its implicit-frame semantic (current row, not partition
15+
/// last) requires explicit-frame support which the simulator doesn't model.
1216
/// </summary>
1317
[TestClass]
1418
public sealed class WindowFunctionTests
@@ -158,4 +162,245 @@ public void RowNumber_CombinedWithGroupBy_NotSupported()
158162
_ = connection.CreateCommand(
159163
"select blog_id, row_number() over(order by blog_id), count(*) from posts group by blog_id").ExecuteScalar());
160164
}
165+
166+
// === RANK / DENSE_RANK ===
167+
168+
/// <summary>
169+
/// Seed for tie-sensitive tests: scores=10,20,20,30 in blog 1; 5,5,50 in blog 2.
170+
/// </summary>
171+
private static DbConnection SeededTies()
172+
{
173+
var connection = new Simulation().CreateOpenConnection();
174+
_ = connection.CreateCommand("""
175+
create table t (id int, grp int, v int);
176+
insert t values (1, 1, 10), (2, 1, 20), (3, 1, 20), (4, 1, 30), (5, 2, 5), (6, 2, 5), (7, 2, 50)
177+
""").ExecuteNonQuery();
178+
return connection;
179+
}
180+
181+
[TestMethod]
182+
public void Rank_TiesShareRank_NextRankSkipsAhead()
183+
{
184+
// RANK with ties: 10 → 1, 20 (tie) → 2, 20 (tie) → 2, 30 → 4 (skips 3).
185+
using var connection = SeededTies();
186+
using var reader = connection.CreateCommand(
187+
"select id, rank() over(order by v) from t where grp = 1").ExecuteReader();
188+
var byId = new Dictionary<int, long>();
189+
while (reader.Read())
190+
byId[reader.GetInt32(0)] = reader.GetInt64(1);
191+
AreEqual(1L, byId[1]);
192+
AreEqual(2L, byId[2]);
193+
AreEqual(2L, byId[3]);
194+
AreEqual(4L, byId[4]);
195+
}
196+
197+
[TestMethod]
198+
public void DenseRank_TiesShareRank_NextRankDoesNotSkip()
199+
{
200+
// DENSE_RANK with ties: 10 → 1, 20 (tie) → 2, 20 (tie) → 2, 30 → 3 (no gap).
201+
using var connection = SeededTies();
202+
using var reader = connection.CreateCommand(
203+
"select id, dense_rank() over(order by v) from t where grp = 1").ExecuteReader();
204+
var byId = new Dictionary<int, long>();
205+
while (reader.Read())
206+
byId[reader.GetInt32(0)] = reader.GetInt64(1);
207+
AreEqual(1L, byId[1]);
208+
AreEqual(2L, byId[2]);
209+
AreEqual(2L, byId[3]);
210+
AreEqual(3L, byId[4]);
211+
}
212+
213+
[TestMethod]
214+
public void Rank_PartitionedResetsPerPartition()
215+
{
216+
using var connection = SeededTies();
217+
using var reader = connection.CreateCommand(
218+
"select id, rank() over(partition by grp order by v) from t").ExecuteReader();
219+
var byId = new Dictionary<int, long>();
220+
while (reader.Read())
221+
byId[reader.GetInt32(0)] = reader.GetInt64(1);
222+
// Group 1: 1→1, 2→2, 3→2, 4→4. Group 2: 5→1, 6→1, 7→3.
223+
AreEqual(1L, byId[5]);
224+
AreEqual(1L, byId[6]);
225+
AreEqual(3L, byId[7]);
226+
}
227+
228+
[TestMethod]
229+
public void Rank_RequiresOrderByInsideOver()
230+
{
231+
using var connection = SeededTies();
232+
_ = Throws<DbException>(() =>
233+
_ = connection.CreateCommand("select rank() over() from t").ExecuteScalar());
234+
}
235+
236+
// === NTILE ===
237+
238+
[TestMethod]
239+
public void NTile_DistributesRowsEvenly()
240+
{
241+
// 6 rows / 3 buckets = 2 rows per bucket.
242+
using var connection = SeededTies();
243+
using var reader = connection.CreateCommand(
244+
"select id, ntile(3) over(order by id) from t where id <= 6").ExecuteReader();
245+
var byId = new Dictionary<int, int>();
246+
while (reader.Read())
247+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
248+
AreEqual(1, byId[1]);
249+
AreEqual(1, byId[2]);
250+
AreEqual(2, byId[3]);
251+
AreEqual(2, byId[4]);
252+
AreEqual(3, byId[5]);
253+
AreEqual(3, byId[6]);
254+
}
255+
256+
[TestMethod]
257+
public void NTile_UnevenDistribution_FirstBucketsLarger()
258+
{
259+
// 7 rows / 3 buckets → smaller=2, remainder=1 → first bucket has 3, rest have 2.
260+
using var connection = SeededTies();
261+
using var reader = connection.CreateCommand(
262+
"select id, ntile(3) over(order by id) from t").ExecuteReader();
263+
var byId = new Dictionary<int, int>();
264+
while (reader.Read())
265+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
266+
// Bucket sizes: [3, 2, 2] → IDs 1,2,3 → bucket 1; 4,5 → 2; 6,7 → 3.
267+
AreEqual(1, byId[1]);
268+
AreEqual(1, byId[2]);
269+
AreEqual(1, byId[3]);
270+
AreEqual(2, byId[4]);
271+
AreEqual(2, byId[5]);
272+
AreEqual(3, byId[6]);
273+
AreEqual(3, byId[7]);
274+
}
275+
276+
[TestMethod]
277+
public void NTile_BucketsExceedRows_OneRowPerBucketThenEmpty()
278+
{
279+
// 3 rows / 5 buckets → first 3 buckets get 1 row each, last 2 buckets are empty.
280+
using var connection = SeededTies();
281+
using var reader = connection.CreateCommand(
282+
"select id, ntile(5) over(order by id) from t where id <= 3").ExecuteReader();
283+
var byId = new Dictionary<int, int>();
284+
while (reader.Read())
285+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
286+
AreEqual(1, byId[1]);
287+
AreEqual(2, byId[2]);
288+
AreEqual(3, byId[3]);
289+
}
290+
291+
[TestMethod]
292+
public void NTile_NonPositiveBucketCount_Raises9819()
293+
{
294+
using var connection = SeededTies();
295+
var ex = Throws<DbException>(() =>
296+
_ = connection.CreateCommand("select ntile(0) over(order by id) from t").ExecuteScalar());
297+
AreEqual("9819", ex.Data["HelpLink.EvtID"]);
298+
}
299+
300+
// === LAG / LEAD ===
301+
302+
[TestMethod]
303+
public void Lag_DefaultOffsetOne_PartitionBoundaryNull()
304+
{
305+
using var connection = SeededTies();
306+
using var reader = connection.CreateCommand(
307+
"select id, lag(v) over(order by id) from t").ExecuteReader();
308+
var byId = new Dictionary<int, int?>();
309+
while (reader.Read())
310+
byId[reader.GetInt32(0)] = reader.IsDBNull(1) ? null : reader.GetInt32(1);
311+
IsNull(byId[1]); // First row: no predecessor → NULL.
312+
AreEqual(10, byId[2]);
313+
AreEqual(20, byId[3]);
314+
AreEqual(20, byId[4]);
315+
AreEqual(30, byId[5]);
316+
}
317+
318+
[TestMethod]
319+
public void Lag_ExplicitOffset_LooksBackFurther()
320+
{
321+
using var connection = SeededTies();
322+
using var reader = connection.CreateCommand(
323+
"select id, lag(v, 2) over(order by id) from t").ExecuteReader();
324+
var byId = new Dictionary<int, int?>();
325+
while (reader.Read())
326+
byId[reader.GetInt32(0)] = reader.IsDBNull(1) ? null : reader.GetInt32(1);
327+
IsNull(byId[1]);
328+
IsNull(byId[2]);
329+
AreEqual(10, byId[3]);
330+
AreEqual(20, byId[4]);
331+
}
332+
333+
[TestMethod]
334+
public void Lag_WithDefaultExpression_SubstitutesAtBoundary()
335+
{
336+
using var connection = SeededTies();
337+
using var reader = connection.CreateCommand(
338+
"select id, lag(v, 1, -99) over(order by id) from t").ExecuteReader();
339+
var byId = new Dictionary<int, int>();
340+
while (reader.Read())
341+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
342+
AreEqual(-99, byId[1]);
343+
AreEqual(10, byId[2]);
344+
}
345+
346+
[TestMethod]
347+
public void Lead_MirrorsLagInOppositeDirection()
348+
{
349+
using var connection = SeededTies();
350+
using var reader = connection.CreateCommand(
351+
"select id, lead(v) over(order by id) from t").ExecuteReader();
352+
var byId = new Dictionary<int, int?>();
353+
while (reader.Read())
354+
byId[reader.GetInt32(0)] = reader.IsDBNull(1) ? null : reader.GetInt32(1);
355+
AreEqual(20, byId[1]);
356+
AreEqual(20, byId[2]);
357+
AreEqual(30, byId[3]);
358+
IsNull(byId[7]); // Last row: no successor → NULL.
359+
}
360+
361+
[TestMethod]
362+
public void Lag_PartitionedDoesNotCrossPartitionBoundary()
363+
{
364+
using var connection = SeededTies();
365+
using var reader = connection.CreateCommand(
366+
"select id, lag(v) over(partition by grp order by id) from t").ExecuteReader();
367+
var byId = new Dictionary<int, int?>();
368+
while (reader.Read())
369+
byId[reader.GetInt32(0)] = reader.IsDBNull(1) ? null : reader.GetInt32(1);
370+
IsNull(byId[1]); // grp 1's first row.
371+
IsNull(byId[5]); // grp 2's first row — wouldn't be NULL if partition was ignored.
372+
}
373+
374+
// === FIRST_VALUE ===
375+
376+
[TestMethod]
377+
public void FirstValue_ReturnsPartitionFirstAfterOrderBy()
378+
{
379+
using var connection = SeededTies();
380+
using var reader = connection.CreateCommand(
381+
"select id, first_value(v) over(partition by grp order by v) from t").ExecuteReader();
382+
var byId = new Dictionary<int, int>();
383+
while (reader.Read())
384+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
385+
// grp 1: smallest v=10 → broadcast across rows 1,2,3,4.
386+
// grp 2: smallest v=5 → broadcast across rows 5,6,7.
387+
AreEqual(10, byId[1]);
388+
AreEqual(10, byId[4]);
389+
AreEqual(5, byId[5]);
390+
AreEqual(5, byId[7]);
391+
}
392+
393+
[TestMethod]
394+
public void FirstValue_RespectsOrderByDirection()
395+
{
396+
// DESC ORDER BY → "first" is the max in each partition.
397+
using var connection = SeededTies();
398+
using var reader = connection.CreateCommand(
399+
"select id, first_value(v) over(partition by grp order by v desc) from t").ExecuteReader();
400+
var byId = new Dictionary<int, int>();
401+
while (reader.Read())
402+
byId[reader.GetInt32(0)] = reader.GetInt32(1);
403+
AreEqual(30, byId[1]); // grp 1's largest v.
404+
AreEqual(50, byId[5]); // grp 2's largest v.
405+
}
161406
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ internal static SimulatedSqlException DistinctNotAllowedInOver() =>
294294
internal static SimulatedSqlException FunctionNotValidForOver(string functionLowerName) =>
295295
new($"The function '{functionLowerName}' is not a valid windowing function, and cannot be used with the OVER clause.", 4113, 15, 4);
296296

297+
/// <summary>
298+
/// Mimics SQL Server's Msg 9819 — <c>NTILE(N)</c> requires <c>N</c> to be
299+
/// a positive number; raised at runtime when the bucket-count expression
300+
/// evaluates to zero or negative.
301+
/// </summary>
302+
internal static SimulatedSqlException NTileBucketCountMustBePositive() =>
303+
new("The function 'NTILE' must have a positive integer value.", 9819, 16, 1);
304+
297305
/// <summary>
298306
/// Mimics SQL Server's Msg 10757 — a non-ordered-set aggregate (anything
299307
/// other than <c>STRING_AGG</c> in this simulator's surface) was given a

SqlServerSimulator/Parser/Expression.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
425425
"COT" => new TrigFunction(context, TrigKind.Cot),
426426
"EXP" => new Exp(context),
427427
"IIF" => new Iif(context),
428+
"LAG" => WindowExpression.ParseLag(context),
428429
"LEN" => new Length(context),
429430
"LOG" => new Log(context),
430431
"MAX" => AggregateExpression.Parse(context, AggregateKind.Max),
@@ -442,7 +443,9 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
442443
"ATAN" => new TrigFunction(context, TrigKind.Atan),
443444
"ATN2" => new Atn2(context),
444445
"CAST" => new Cast(context),
446+
"LEAD" => WindowExpression.ParseLead(context),
445447
"LEFT" => new Left(context),
448+
"RANK" => WindowExpression.ParseRank(context),
446449
"SIGN" => new Sign(context),
447450
"SQRT" => new Sqrt(context),
448451
"TRIM" => new Trim(context),
@@ -457,6 +460,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
457460
"LOWER" => new Lower(context),
458461
"LTRIM" => new LeftTrim(context),
459462
"NEWID" => new NewId(context),
463+
"NTILE" => WindowExpression.ParseNTile(context),
460464
"POWER" => new Power(context),
461465
"RIGHT" => new Right(context),
462466
"ROUND" => new Round(context),
@@ -509,6 +513,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
509513
10 => uppercaseName switch
510514
{
511515
"DATALENGTH" => new DataLength(context),
516+
"DENSE_RANK" => WindowExpression.ParseDenseRank(context),
512517
"ERROR_LINE" => new ErrorLineFunction(context),
513518
"GETUTCDATE" => new CurrentTimeFunction(context, CurrentTimeKind.GetUtcDate),
514519
"JSON_VALUE" => new JsonValue(context),
@@ -519,6 +524,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
519524
11 => uppercaseName switch
520525
{
521526
"ERROR_STATE" => new ErrorStateFunction(context),
527+
"FIRST_VALUE" => WindowExpression.ParseFirstValue(context),
522528
"JSON_MODIFY" => new JsonModify(context),
523529
"SYSDATETIME" => new CurrentTimeFunction(context, CurrentTimeKind.SysDateTime),
524530
"TRY_CONVERT" => new ConvertExpression(context, tryMode: true),

0 commit comments

Comments
 (0)