Skip to content

Commit 8256dad

Browse files
committed
Added OFFSET n ROWS [FETCH NEXT|FIRST k ROW|ROWS ONLY] pagination attached to ORDER BY for single-SELECT, post-set-op, derived-table, and aggregate paths, with TOP/OFFSET mutual exclusion (Msg 10741) and parse-time validation (Msg 153 / 10742 / 10744).
1 parent 9bf04c0 commit 8256dad

7 files changed

Lines changed: 511 additions & 51 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ Searched (`CASE WHEN cond THEN ... [ELSE ...] END`) and simple (`CASE input WHEN
146146

147147
All forms work correlated and non-correlated, arbitrary nesting depth. Inner plan re-executes per outer row (no caching — fidelity over performance).
148148

149+
### Pagination (`OFFSET ... FETCH`)
150+
`OFFSET n ROWS [FETCH NEXT|FIRST k ROW|ROWS ONLY]` attached to ORDER BY. `ROW`/`ROWS` and `NEXT`/`FIRST` are interchangeable. OFFSET requires ORDER BY (no ORDER BY → Msg 102 generic syntax error). FETCH alone (no preceding OFFSET) → **Msg 153** `"Invalid usage of the option next in the FETCH statement."` Counts resolve at parse time (constants, parameters, arithmetic like `1+1`); validated at parse: negative offset → **Msg 10742** `"The offset specified in a OFFSET clause may not be negative."` (verbatim "a OFFSET"); fetch ≤ 0 → **Msg 10744** `"... must be greater then zero."` (verbatim typo "then").
151+
152+
TOP and OFFSET are mutually exclusive on the same SELECT — both present → **Msg 10741** `"A TOP can not be used in the same query or sub-query as a OFFSET."` Detected at parse time before any rows are read.
153+
154+
Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDER BY in `ApplyTopLevelOrderBy` and operates on the combined result. Per-branch OFFSET/FETCH in a non-final set-op branch inherits the existing Msg 156 rejection (since OFFSET requires ORDER BY which is already rejected per-branch). Inside a derived table: works (the inner SELECT is its own scope).
155+
149156
### Aggregates
150157
`COUNT(*)` / `COUNT(expr)` / `COUNT(DISTINCT expr)` / `COUNT_BIG`, `SUM` / `AVG` (integer-truncating; `decimal(38, max(s, 6))` widening for AVG over decimals), `MAX` / `MIN`, statistical (`STDEV` / `STDEVP` / `VAR` / `VARP`), `STRING_AGG`, `CHECKSUM_AGG`, `APPROX_COUNT_DISTINCT`. Standalone and inside `GROUP BY` / `HAVING`.
151158

SqlServerSimulator.Tests.EFCore/EFCoreOrderingAndDistinct.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ public void OrderBy_Take_ReturnsFirstNRowsDeterministically()
9898
CollectionAssert.AreEqual(new[] { 1, 2 }, top2);
9999
}
100100

101+
[TestMethod]
102+
public void OrderBy_Skip_Take_PaginatesViaOffsetFetch()
103+
{
104+
// EF Core translates Skip(n).Take(m) over an ordered query into
105+
// ORDER BY ... OFFSET n ROWS FETCH NEXT m ROWS ONLY. This test
106+
// pins the simulator's pagination plumbing against the real EF
107+
// emit shape rather than the hand-written SQL.
108+
using var context = SeedPeople();
109+
var page2 = context.People.OrderBy(p => p.Id).Select(p => p.Id).Skip(1).Take(2).ToArray();
110+
CollectionAssert.AreEqual(new[] { 2, 3 }, page2);
111+
}
112+
113+
[TestMethod]
114+
public void OrderBy_Skip_AllRowsAfterOffset()
115+
{
116+
// Skip without Take → OFFSET n ROWS (no FETCH). EF Core supports
117+
// this and the simulator must too.
118+
using var context = SeedPeople();
119+
var afterFirst = context.People.OrderBy(p => p.Id).Select(p => p.Id).Skip(2).ToArray();
120+
CollectionAssert.AreEqual(new[] { 3, 4 }, afterFirst);
121+
}
122+
101123
[TestMethod]
102124
public void Distinct_OnSingleColumn_RemovesDuplicates()
103125
{
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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 Server's <c>OFFSET ... ROWS [FETCH NEXT|FIRST n
8+
/// ROWS|ROW ONLY]</c> pagination clause, attached to ORDER BY. Covers
9+
/// syntactic synonyms (NEXT/FIRST, ROW/ROWS), required-ORDER-BY rule,
10+
/// FETCH-without-OFFSET rejection (Msg 153), negative-offset (Msg 10742) /
11+
/// non-positive-fetch (Msg 10744) rejection, TOP/OFFSET mutual exclusion
12+
/// (Msg 10741), parameter binding, expression evaluation, and integration
13+
/// with ORDER BY / WHERE / GROUP BY / derived tables / set-op chains.
14+
/// Sourced from probes against SQL Server 2025.
15+
/// </summary>
16+
[TestClass]
17+
public sealed class PaginationTests
18+
{
19+
private static List<int> ReadInts(DbCommand command)
20+
{
21+
using var reader = command.ExecuteReader();
22+
var values = new List<int>();
23+
while (reader.Read())
24+
values.Add(reader.GetInt32(0));
25+
return values;
26+
}
27+
28+
private static Simulation SeededFiveRows()
29+
{
30+
var simulation = new Simulation();
31+
_ = simulation.ExecuteNonQuery("create table t (id int)");
32+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3), (4), (5)");
33+
return simulation;
34+
}
35+
36+
// === Basic syntax variations ===
37+
38+
[TestMethod]
39+
public void OffsetZero_ReturnsAllRows()
40+
{
41+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 0 rows"));
42+
CollectionAssert.AreEqual(new[] { 1, 2, 3, 4, 5 }, values);
43+
}
44+
45+
[TestMethod]
46+
public void OffsetTwo_SkipsFirstTwo()
47+
{
48+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 2 rows"));
49+
CollectionAssert.AreEqual(new[] { 3, 4, 5 }, values);
50+
}
51+
52+
[TestMethod]
53+
public void OffsetFetchNext_LimitsAfterSkip()
54+
{
55+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 1 rows fetch next 2 rows only"));
56+
CollectionAssert.AreEqual(new[] { 2, 3 }, values);
57+
}
58+
59+
[TestMethod]
60+
public void FetchFirst_IsSynonymForFetchNext()
61+
{
62+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 0 rows fetch first 3 rows only"));
63+
CollectionAssert.AreEqual(new[] { 1, 2, 3 }, values);
64+
}
65+
66+
[TestMethod]
67+
public void RowSingular_EquivalentToRowsPlural()
68+
{
69+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 1 row fetch next 1 row only"));
70+
CollectionAssert.AreEqual(new[] { 2 }, values);
71+
}
72+
73+
// === Boundary values ===
74+
75+
[TestMethod]
76+
public void OffsetLargerThanResult_ReturnsEmpty()
77+
{
78+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 99 rows"));
79+
IsEmpty(values);
80+
}
81+
82+
[TestMethod]
83+
public void FetchLargerThanRemaining_ReturnsRemainder()
84+
{
85+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 3 rows fetch next 99 rows only"));
86+
CollectionAssert.AreEqual(new[] { 4, 5 }, values);
87+
}
88+
89+
// === Errors ===
90+
91+
[TestMethod]
92+
public void NegativeOffset_RaisesMsg10742()
93+
{
94+
var ex = Throws<DbException>(() =>
95+
_ = SeededFiveRows().ExecuteScalar("select id from t order by id offset -1 rows"));
96+
AreEqual("10742", ex.Data["HelpLink.EvtID"]);
97+
AreEqual("The offset specified in a OFFSET clause may not be negative.", ex.Message);
98+
}
99+
100+
[TestMethod]
101+
public void FetchZero_RaisesMsg10744()
102+
{
103+
var ex = Throws<DbException>(() =>
104+
_ = SeededFiveRows().ExecuteScalar("select id from t order by id offset 0 rows fetch next 0 rows only"));
105+
AreEqual("10744", ex.Data["HelpLink.EvtID"]);
106+
AreEqual("The number of rows provided for a FETCH clause must be greater then zero.", ex.Message);
107+
}
108+
109+
[TestMethod]
110+
public void NegativeFetch_RaisesMsg10744()
111+
{
112+
var ex = Throws<DbException>(() =>
113+
_ = SeededFiveRows().ExecuteScalar("select id from t order by id offset 0 rows fetch next -1 rows only"));
114+
AreEqual("10744", ex.Data["HelpLink.EvtID"]);
115+
}
116+
117+
[TestMethod]
118+
public void FetchWithoutOffset_RaisesMsg153()
119+
{
120+
var ex = Throws<DbException>(() =>
121+
_ = SeededFiveRows().ExecuteScalar("select id from t order by id fetch next 2 rows only"));
122+
AreEqual("153", ex.Data["HelpLink.EvtID"]);
123+
AreEqual("Invalid usage of the option next in the FETCH statement.", ex.Message);
124+
}
125+
126+
[TestMethod]
127+
public void TopWithOffset_RaisesMsg10741()
128+
{
129+
var ex = Throws<DbException>(() =>
130+
_ = SeededFiveRows().ExecuteScalar("select top 2 id from t order by id offset 1 rows fetch next 2 rows only"));
131+
AreEqual("10741", ex.Data["HelpLink.EvtID"]);
132+
AreEqual("A TOP can not be used in the same query or sub-query as a OFFSET.", ex.Message);
133+
}
134+
135+
[TestMethod]
136+
public void TopWithOffsetOnly_AlsoRaisesMsg10741()
137+
{
138+
// Even without a FETCH clause: TOP + OFFSET still mutually exclusive.
139+
var ex = Throws<DbException>(() =>
140+
_ = SeededFiveRows().ExecuteScalar("select top 2 id from t order by id offset 1 rows"));
141+
AreEqual("10741", ex.Data["HelpLink.EvtID"]);
142+
}
143+
144+
// === Expressions and parameters ===
145+
146+
[TestMethod]
147+
public void OffsetFetch_AcceptsArithmeticExpression()
148+
{
149+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id offset 1+1 rows fetch next 1+1 rows only"));
150+
CollectionAssert.AreEqual(new[] { 3, 4 }, values);
151+
}
152+
153+
[TestMethod]
154+
public void OffsetFetch_AcceptsParameters()
155+
{
156+
using var connection = SeededFiveRows().CreateOpenConnection();
157+
using var command = connection.CreateCommand("select id from t order by id offset @skip rows fetch next @take rows only");
158+
var skip = command.CreateParameter();
159+
skip.ParameterName = "@skip";
160+
skip.Value = 2;
161+
_ = command.Parameters.Add(skip);
162+
var take = command.CreateParameter();
163+
take.ParameterName = "@take";
164+
take.Value = 2;
165+
_ = command.Parameters.Add(take);
166+
167+
var values = new List<int>();
168+
using var reader = command.ExecuteReader();
169+
while (reader.Read())
170+
values.Add(reader.GetInt32(0));
171+
CollectionAssert.AreEqual(new[] { 3, 4 }, values);
172+
}
173+
174+
// === ORDER direction and combinations ===
175+
176+
[TestMethod]
177+
public void OffsetFetch_AppliesAfterOrderByDescending()
178+
{
179+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t order by id desc offset 1 rows fetch next 2 rows only"));
180+
CollectionAssert.AreEqual(new[] { 4, 3 }, values);
181+
}
182+
183+
[TestMethod]
184+
public void OffsetFetch_AppliesAfterWhere()
185+
{
186+
var values = ReadInts(SeededFiveRows().CreateCommand("select id from t where id >= 2 order by id offset 1 rows fetch next 2 rows only"));
187+
CollectionAssert.AreEqual(new[] { 3, 4 }, values);
188+
}
189+
190+
// === Tableless SELECT ===
191+
192+
[TestMethod]
193+
public void TablelessSelect_OffsetZero_ReturnsRow()
194+
{
195+
var values = ReadInts(new Simulation().CreateCommand("select 1 order by 1 offset 0 rows fetch next 1 rows only"));
196+
CollectionAssert.AreEqual(new[] { 1 }, values);
197+
}
198+
199+
[TestMethod]
200+
public void TablelessSelect_OffsetOne_ReturnsEmpty()
201+
{
202+
var values = ReadInts(new Simulation().CreateCommand("select 1 order by 1 offset 1 rows"));
203+
IsEmpty(values);
204+
}
205+
206+
// === Derived tables ===
207+
208+
[TestMethod]
209+
public void OffsetFetch_InsideDerivedTable()
210+
{
211+
var values = ReadInts(SeededFiveRows().CreateCommand(
212+
"select v.id from (select id from t order by id offset 1 rows fetch next 2 rows only) v"));
213+
CollectionAssert.AreEqual(new[] { 2, 3 }, values);
214+
}
215+
216+
// === Aggregate path (GROUP BY) ===
217+
218+
[TestMethod]
219+
public void OffsetFetch_OverGroupedAggregate()
220+
{
221+
var simulation = new Simulation();
222+
_ = simulation.ExecuteNonQuery("create table g (k int, v int)");
223+
_ = simulation.ExecuteNonQuery("insert into g values (1, 10), (1, 20), (2, 30), (3, 40), (4, 50)");
224+
225+
var values = ReadInts(simulation.CreateCommand(
226+
"select k from g group by k order by k offset 1 rows fetch next 2 rows only"));
227+
CollectionAssert.AreEqual(new[] { 2, 3 }, values);
228+
}
229+
230+
// === Set-op chains ===
231+
232+
[TestMethod]
233+
public void TopLevelOffsetFetch_AppliesToSetOpResult()
234+
{
235+
// (1, 2, 3, 4, 5) UNION (3, 4, 5, 6) deduped → {1,2,3,4,5,6}; ORDER BY v OFFSET 2 FETCH NEXT 2 → {3,4}.
236+
var simulation = new Simulation();
237+
_ = simulation.ExecuteNonQuery("create table left_t (v int)");
238+
_ = simulation.ExecuteNonQuery("create table right_t (v int)");
239+
_ = simulation.ExecuteNonQuery("insert into left_t values (1), (2), (3), (4), (5)");
240+
_ = simulation.ExecuteNonQuery("insert into right_t values (3), (4), (5), (6)");
241+
242+
using var connection = simulation.CreateOpenConnection();
243+
var values = ReadInts(connection.CreateCommand(
244+
"select v from left_t union select v from right_t order by v offset 2 rows fetch next 2 rows only"));
245+
CollectionAssert.AreEqual(new[] { 3, 4 }, values);
246+
}
247+
248+
[TestMethod]
249+
public void PerBranchOffset_RejectedAsPerBranchOrderBy()
250+
{
251+
// OFFSET requires ORDER BY, and per-branch ORDER BY before a set-op
252+
// already raises Msg 156. So OFFSET in a non-final branch falls
253+
// through that existing rejection path.
254+
var ex = Throws<DbException>(() =>
255+
_ = new Simulation().ExecuteScalar(
256+
"select 1 as v order by v offset 0 rows fetch next 1 rows only union select 2"));
257+
AreEqual("156", ex.Data["HelpLink.EvtID"]);
258+
}
259+
}

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ enum ContextualKeyword
2222
_ = 0, // Default — current token isn't a contextual keyword.
2323
Compatibility_Level,
2424
Configuration,
25+
First,
2526
Matched,
2627
Max,
28+
Next,
29+
Offset,
30+
Only,
2731
Output,
2832
Persisted,
33+
Row,
34+
Rows,
2935
Scoped,
3036
TraceOff,
3137
TraceOn,

0 commit comments

Comments
 (0)