Skip to content

Commit e4cd012

Browse files
committed
Explicit window frames + LAST_VALUE: new FrameSpec / FrameBound types capture ROWS BETWEEN / RANGE BETWEEN with all five bound shapes (UNBOUNDED PRECEDING / N PRECEDING / CURRENT ROW / N FOLLOWING / UNBOUNDED FOLLOWING) and the single-bound shorthand (ROWS UNBOUNDED PRECEDING ≡ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). Parser's RejectFrameSpec now raises Msg 10752 with the offending function name for ranking (ROW_NUMBER / RANK / DENSE_RANK / NTILE) and offset (LAG /LEAD) functions; FIRST_VALUE / LAST_VALUE / aggregate-OVER accept frames; frame without ORDER BY → Msg 10756; BETWEEN N FOLLOWING AND N PRECEDING|CURRENT ROW → Msg 4193; RANGE with numeric-offset bounds → Msg 4194 (probe-confirmed matches real SQL Server's separately-licensed-feature gate). Executor's new ComputeFrameExtent walks the no-OB-no-frame → whole partition / OB-only → default RANGE UNBOUNDED PRECEDING TO CURRENT ROW (running total with peer-tie grouping) / explicit-frame → ROWS row-precise arithmetic + RANGE CURRENT ROW peer-scan decision tree, with theoretical-then-clamp empty-frame detection (out-of-partition bounds → typed NULL for SUM/AVG/MIN/MAX/FIRST_VALUE/LAST_VALUE, 0 for COUNT). LAST_VALUE added as a new WindowKind sharing the FIRST_VALUE parse / execute paths keyed on Kind; aggregate-OVER's previous "ORDER BY → NotSupportedException" restriction lifted, with a whole-partition fast path retained when no frame applies. 27 new WindowFrameTests cover running totals (RANGE peer-ties + ROWS row-precise), sliding/expanding/whole-partition aggregates, MIN/MAX/AVG over frames, single-bound shorthand, LAST_VALUE under default (= current-row peer-tie last) vs explicit ROWS UNBOUNDED-to-UNBOUNDED (= partition last), FIRST_VALUE with explicit frame, and every rejection path (10752 across all six disallowed kinds via DataRow, 10756, 4193, 4194, 102 for invalid unbounded ends, 4112 for LAST_VALUE missing ORDER BY). WindowAggregateTests.NotSupportedShapes shrunk: ORDER-BY-in-OVER row deleted (works now), the two frame-without-ORDER-BY rows moved to WindowParserRejections asserting Msg 10756. CLAUDE.md "Not modeled" entry collapsed to RANGE-numeric-offset only; docs/claude/query.md expanded with the full frame grammar, default-frame logic, empty-frame semantics, and per-row extent execution model.
1 parent b15b721 commit e4cd012

10 files changed

Lines changed: 921 additions & 83 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
158158
- Comma-separated FROM (legacy ANSI-89 join syntax).
159159
- `ANY` / `SOME` / `ALL` quantifiers.
160160
- Row-constructor `IN ((1,2), (3,4))`.
161-
- `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.
162-
- 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.
161+
- `RANGE BETWEEN <N> PRECEDING` / `<N> FOLLOWING` — real SQL Server gates the numeric-offset RANGE form behind a separately-licensed feature surface and the simulator matches that rejection (Msg 4194). `ROWS` numeric-offset frames ship; both modes support the canonical `UNBOUNDED` / `CURRENT ROW` bounds and the single-bound shorthand (`ROWS UNBOUNDED PRECEDING``ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). The default frame when ORDER BY is present in OVER is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW` (running-total semantic with peer-tie grouping); without ORDER BY, default frame is whole partition. LAST_VALUE ships with the same semantics as real SQL Server (its default frame returns the current row's value or the peer-tie last under RANGE — the intuitive "partition last" form needs explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`).
163162
- 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.
164163
- `LIKE` with `COLLATE` override (default collation only).
165164
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.

SqlServerSimulator.Tests/WindowAggregateTests.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ insert b values (1, 1000000000000), (1, 1000000000000)
292292
[DataRow("select region from sales where sum(amount) over(partition by region) > 100", 4108)]
293293
[DataRow("select region from sales group by region having sum(amount) over(partition by region) > 100", 4108)]
294294
[DataRow("select region from sales group by sum(amount) over(partition by region)", 4108)]
295+
// Frame without ORDER BY → Msg 10756 (probe-confirmed). Applies to both
296+
// ROWS and RANGE.
297+
[DataRow("select sum(amount) over(partition by region rows between unbounded preceding and current row) from sales", 10756)]
298+
[DataRow("select sum(amount) over(partition by region range between unbounded preceding and current row) from sales", 10756)]
295299
public void WindowParserRejections(string sql, int errorNumber)
296300
{
297301
using var connection = SeededSales();
@@ -300,9 +304,6 @@ public void WindowParserRejections(string sql, int errorNumber)
300304
}
301305

302306
[TestMethod]
303-
[DataRow("select sum(amount) over(partition by region order by amount) from sales")]
304-
[DataRow("select sum(amount) over(partition by region rows between unbounded preceding and current row) from sales")]
305-
[DataRow("select sum(amount) over(partition by region range between unbounded preceding and current row) from sales")]
306307
[DataRow("select region, sum(amount), sum(amount) over() from sales group by region")]
307308
public void NotSupportedShapes(string sql)
308309
{

SqlServerSimulator.Tests/WindowFrameTests.cs

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,50 @@ internal static SimulatedSqlException FunctionNotValidForOver(string functionLow
302302
internal static SimulatedSqlException NTileBucketCountMustBePositive() =>
303303
new("The function 'NTILE' must have a positive integer value.", 9819, 16, 1);
304304

305+
/// <summary>
306+
/// Mimics SQL Server's Msg 10752 — an explicit <c>ROWS</c> / <c>RANGE</c>
307+
/// frame specification was supplied for a function that doesn't accept
308+
/// one (ranking functions: <c>row_number</c> / <c>rank</c> /
309+
/// <c>dense_rank</c> / <c>ntile</c>; offset functions: <c>lag</c> /
310+
/// <c>lead</c>). Probe-confirmed against SQL Server 2025 (2026-05-12):
311+
/// Class 15, State 1 for LAG/LEAD and State 3 for ranking; the simulator
312+
/// uses State 1 uniformly (matching the LAG/LEAD probe; State 3 vs 1
313+
/// isn't routed through any caller behavior).
314+
/// </summary>
315+
internal static SimulatedSqlException FunctionMayNotHaveWindowFrame(string functionLowerName) =>
316+
new($"The function '{functionLowerName}' may not have a window frame.", 10752, 15, 1);
317+
318+
/// <summary>
319+
/// Mimics SQL Server's Msg 10756 — an explicit <c>ROWS</c> or <c>RANGE</c>
320+
/// frame was supplied without an <c>ORDER BY</c> clause inside the same
321+
/// <c>OVER</c>. Probe-confirmed against SQL Server 2025 (2026-05-12):
322+
/// Class 15, State 1.
323+
/// </summary>
324+
internal static SimulatedSqlException WindowFrameRequiresOrderBy() =>
325+
new("Window frame with ROWS or RANGE must have an ORDER BY clause.", 10756, 15, 1);
326+
327+
/// <summary>
328+
/// Mimics SQL Server's Msg 4193 — the frame's start bound is
329+
/// <c>N FOLLOWING</c> and the end bound is <c>N PRECEDING</c> or
330+
/// <c>CURRENT ROW</c>, which is semantically invalid (end before start).
331+
/// Probe-confirmed against SQL Server 2025 (2026-05-12): Class 16,
332+
/// State 1.
333+
/// </summary>
334+
internal static SimulatedSqlException FrameBetweenFollowingAndPreceding() =>
335+
new("'BETWEEN ... FOLLOWING AND ... PRECEDING' is not a valid window frame and cannot be used with the OVER clause.", 4193, 16, 1);
336+
337+
/// <summary>
338+
/// Mimics SQL Server's Msg 4194 — a <c>RANGE</c> frame used a numeric-
339+
/// offset bound (<c>N PRECEDING</c> / <c>N FOLLOWING</c>). Real SQL
340+
/// Server restricts <c>RANGE</c> to <c>UNBOUNDED PRECEDING</c> /
341+
/// <c>UNBOUNDED FOLLOWING</c> / <c>CURRENT ROW</c> (the value-based
342+
/// offset form requires a separately licensed feature surface).
343+
/// Probe-confirmed against SQL Server 2025 (2026-05-12): Class 16,
344+
/// State 1.
345+
/// </summary>
346+
internal static SimulatedSqlException RangeFrameOnlySupportsUnboundedAndCurrentRow() =>
347+
new("RANGE is only supported with UNBOUNDED and CURRENT ROW window frame delimiters.", 4194, 16, 1);
348+
305349
/// <summary>
306350
/// Mimics SQL Server's Msg 10757 — a non-ordered-set aggregate (anything
307351
/// other than <c>STRING_AGG</c> in this simulator's surface) was given a

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ enum ContextualKeyword
3232
Delay,
3333
Encryption,
3434
First,
35+
Following,
3536
Increment,
3637
Input,
3738
Log,
@@ -49,6 +50,7 @@ enum ContextualKeyword
4950
Output,
5051
Partition,
5152
Persisted,
53+
Preceding,
5254
Range,
5355
ReadOnly,
5456
Recompile,
@@ -68,6 +70,7 @@ enum ContextualKeyword
6870
TraceOn,
6971
Try,
7072
Type,
73+
Unbounded,
7174
Using,
7275
Value,
7376
Verbose_Truncation_Warnings,

SqlServerSimulator/Parser/Expression.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ private static Expression ResolveBuiltIn(string name, ParserContext context)
527527
"ERROR_LINE" => new ErrorLineFunction(context),
528528
"GETUTCDATE" => new CurrentTimeFunction(context, CurrentTimeKind.GetUtcDate),
529529
"JSON_VALUE" => new JsonValue(context),
530+
"LAST_VALUE" => WindowExpression.ParseLastValue(context),
530531
"ROW_NUMBER" => WindowExpression.ParseRowNumber(context),
531532
"STRING_AGG" => AggregateExpression.Parse(context, AggregateKind.StringAgg),
532533
_ => null
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
namespace SqlServerSimulator.Parser.Expressions;
2+
3+
/// <summary>
4+
/// Kind of a single frame boundary, used for the <c>start</c> and <c>end</c>
5+
/// of an explicit window frame (<c>ROWS BETWEEN &lt;start&gt; AND &lt;end&gt;</c>).
6+
/// </summary>
7+
internal enum FrameBoundKind
8+
{
9+
UnboundedPreceding,
10+
NPreceding,
11+
CurrentRow,
12+
NFollowing,
13+
UnboundedFollowing,
14+
}
15+
16+
/// <summary>
17+
/// A single frame boundary. <see cref="Offset"/> is meaningful only for
18+
/// <see cref="FrameBoundKind.NPreceding"/> / <see cref="FrameBoundKind.NFollowing"/>;
19+
/// it's the constant integer literal that was supplied (probed: real SQL
20+
/// Server requires a non-negative integer literal here — non-literal
21+
/// expressions raise Msg 102 at parse, negative ones Msg 1014).
22+
/// </summary>
23+
internal readonly record struct FrameBound(FrameBoundKind Kind, long Offset)
24+
{
25+
public static readonly FrameBound UnboundedPreceding = new(FrameBoundKind.UnboundedPreceding, 0);
26+
public static readonly FrameBound CurrentRow = new(FrameBoundKind.CurrentRow, 0);
27+
public static readonly FrameBound UnboundedFollowing = new(FrameBoundKind.UnboundedFollowing, 0);
28+
public static FrameBound NPreceding(long offset) => new(FrameBoundKind.NPreceding, offset);
29+
public static FrameBound NFollowing(long offset) => new(FrameBoundKind.NFollowing, offset);
30+
}
31+
32+
/// <summary>
33+
/// Explicit window frame (<c>ROWS BETWEEN x AND y</c> or <c>RANGE BETWEEN x AND y</c>).
34+
/// Captured at parse, applied per-row at execute. <see cref="IsRange"/>
35+
/// distinguishes RANGE (peer-tie groups share frame extents) from ROWS
36+
/// (each row gets its own extent by offset arithmetic).
37+
/// </summary>
38+
internal sealed class FrameSpec(bool isRange, FrameBound start, FrameBound end)
39+
{
40+
public readonly bool IsRange = isRange;
41+
42+
public readonly FrameBound Start = start;
43+
44+
public readonly FrameBound End = end;
45+
}

0 commit comments

Comments
 (0)