Skip to content

Commit 58bc632

Browse files
committed
Initial implementation of CURSORs.
1 parent 86374e1 commit 58bc632

18 files changed

Lines changed: 1591 additions & 27 deletions

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ Three entry points share one per-connection undo log: implicit (statement-level
127127
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
128128
- `MERGE INTO target USING <source> ON … WHEN [NOT] MATCHED [BY TARGET|SOURCE] [AND …] THEN UPDATE|DELETE|INSERT … [OUTPUT $action, …]` — source = VALUES / SELECT / set-op / bare-table; multiple per-family AND-conditioned clauses; trailing `;` required. Msg 5324 / 8672 / 10713 / 10714 enforced. Triggers fire one INSERT → UPDATE → DELETE pass with combined affected rows. See [`docs/claude/dml.md`](docs/claude/dml.md).
129129

130+
### Cursors
131+
Session-scoped T-SQL cursors: `DECLARE <name> [INSENSITIVE][SCROLL] CURSOR [LOCAL|GLOBAL][FORWARD_ONLY|SCROLL][STATIC|KEYSET|DYNAMIC|FAST_FORWARD][READ_ONLY|…] FOR <select> [FOR {READ ONLY|UPDATE [OF cols]}]`, `OPEN`, `FETCH NEXT|PRIOR|FIRST|LAST|ABSOLUTE n|RELATIVE n [FROM] <cursor> [INTO @v,…]`, `CLOSE`, `DEALLOCATE`, plus `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS` and positioned `WHERE CURRENT OF` UPDATE/DELETE. Effective sensitivity resolves at DECLARE: non-updatable query (not a single base table with a PK/UNIQUE) → STATIC; STATIC frozen-snapshot, KEYSET frozen-membership with live value re-reads (deleted member → `@@FETCH_STATUS=-2`), DYNAMIC fully live (skips deletes, sees inserts, `@@CURSOR_ROWS=-1`). Position tracked by the logical unique key (not a RID — UPDATE relocates rows). Errors Msg 16905/16915/16916/16917/16924/16925/16929/16931 probe-confirmed verbatim. Cursor variables (`DECLARE @c CURSOR`) raise `NotSupportedException`. See [`docs/claude/cursors.md`](docs/claude/cursors.md).
132+
130133
### EF Core adapter coverage
131134
`UseSqlServerSimulator(...)` covers seven SqlParameter-downcast pairs: `DateOnly→date`, `DateTime→date`, `DateTime→smalldatetime`, `TimeOnly→time(N)`, `TimeSpan→time(N)`, `decimal→money`, `decimal→smallmoney`. Without the adapter those mappings throw at SaveChanges. MAX-string family flows through plain `UseSqlServer`.
132135

@@ -146,6 +149,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
146149
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
147150
- **UPDATE / DELETE / INSERT…SELECT / SELECT…INTO / rowversion (incl. `@@DBTS` / `MIN_ACTIVE_ROWVERSION`) / identity helpers (`@@IDENTITY` / `SCOPE_IDENTITY` / `IDENT_CURRENT` / `IDENT_INCR` / `IDENT_SEED`) / `@@ROWCOUNT` / `ROWCOUNT_BIG` / OUTPUT / MERGE**[`dml.md`](docs/claude/dml.md).
148151
- **Variables, control flow (IF/WHILE/BREAK/CONTINUE/RETURN), TRY/CATCH+THROW+ERROR_*, `@@ERROR` / `@@TRANCOUNT` / `XACT_STATE`, PRINT, WAITFOR**[`control-flow.md`](docs/claude/control-flow.md).
152+
- **Cursors (`DECLARE … CURSOR` / `OPEN` / `FETCH` / `CLOSE` / `DEALLOCATE`, STATIC / KEYSET / DYNAMIC sensitivity, scroll fetches, `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS`, `WHERE CURRENT OF`)**[`cursors.md`](docs/claude/cursors.md).
149153
- **CTE shapes / recursive-CTE error handling**[`ctes.md`](docs/claude/ctes.md).
150154
- **JSON_VALUE / JSON_QUERY / JSON_MODIFY / JSON_OBJECT / JSON_ARRAY / JSON_PATH_EXISTS / ISJSON / OPENJSON**[`json.md`](docs/claude/json.md).
151155
- **Name resolution, schema lookup, CREATE / DROP / ALTER SCHEMA TRANSFER, `OBJECT_ID` / `OBJECT_NAME` / `OBJECT_SCHEMA_NAME` / `SCHEMA_ID` / `SCHEMA_NAME` / `DB_ID` / `DB_NAME`, cross-DB read routing**[`schemas.md`](docs/claude/schemas.md).
@@ -225,3 +229,4 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
225229
- **`STRING_SPLIT(..., ..., cast(@v as int))` — wrapped variable accepted**: the simulator's `enable_ordinal` const-only gate rejects bare `@v` (Msg 8748, matching real) but a Cast / Parenthesized wrapper around the variable slips past the gate. Real SQL Server rejects all variable-bearing shapes regardless of wrapping. No real-world emission hits this — the const-only restriction means `@v` ergonomics are weak in any form.
226230
- **`OPENJSON` `WITH ... AS JSON`** on `nvarchar(max)` raises `NotSupportedException` even though real SQL Server accepts the column form there (sub-tree extraction not modeled). Non-`nvarchar(max)` columns with `AS JSON` raise **Msg 13618**, matching real.
227231
- **`FOR SYSTEM_TIME` qualified-name format**: real SQL Server pads temp-table names in Msg 13544 with their internal allocation suffix (`#x____...___…000000000148`); the simulator emits the bare `tempdb.dbo.#x` form. Same Msg number / framing, less verbose name.
232+
- **Cursor edges**: `@@CURSOR_ROWS` is `-1` throughout for DYNAMIC (real SQL Server may report a transient positive count pre-fetch); a keyset `-2` (deleted-member) fetch leaves the `INTO` variables unchanged rather than zeroing/NULLing them; `FETCH` without `INTO` omits the trailing `ROWSTAT` column real SQL Server appends. See [`docs/claude/cursors.md`](docs/claude/cursors.md).
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// T-SQL cursors: DECLARE … CURSOR / OPEN / FETCH / CLOSE / DEALLOCATE,
8+
/// the STATIC / KEYSET / DYNAMIC sensitivity model, scroll fetches,
9+
/// <c>@@FETCH_STATUS</c> / <c>@@CURSOR_ROWS</c> / <c>CURSOR_STATUS</c>, and
10+
/// positioned <c>WHERE CURRENT OF</c> DML. Behavior probed against SQL Server
11+
/// 2025. A cursor is session-scoped, so each lifecycle runs as one batch
12+
/// string on a single connection.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class CursorTests
16+
{
17+
private const string Seed =
18+
"create table t (id int primary key, name varchar(20)); " +
19+
"insert t values (1,'a'),(2,'b'),(3,'c');";
20+
21+
[TestMethod]
22+
public void ForwardLoop_AccumulatesAllRowsInOrder()
23+
=> AreEqual("1:a;2:b;3:c;", new Simulation().ExecuteScalar(Seed + """
24+
declare @id int, @name varchar(20), @log varchar(200) = '';
25+
declare c cursor for select id, name from t order by id;
26+
open c;
27+
fetch next from c into @id, @name;
28+
while @@fetch_status = 0
29+
begin
30+
set @log = @log + convert(varchar, @id) + ':' + @name + ';';
31+
fetch next from c into @id, @name;
32+
end
33+
close c; deallocate c;
34+
select @log
35+
"""));
36+
37+
[TestMethod]
38+
public void FetchPastEnd_FetchStatusIsMinusOne()
39+
=> AreEqual(-1, ExecuteScalar<int>(Seed + """
40+
declare @id int, @name varchar(20);
41+
declare c cursor for select id, name from t;
42+
open c;
43+
while @@fetch_status = 0 fetch next from c into @id, @name;
44+
select @@fetch_status
45+
"""));
46+
47+
[TestMethod]
48+
public void FetchPastEnd_VariablesRetainLastFetchedValue()
49+
=> AreEqual(3, ExecuteScalar<int>(Seed + """
50+
declare @id int, @name varchar(20);
51+
declare c cursor for select id, name from t order by id;
52+
open c;
53+
while @@fetch_status = 0 fetch next from c into @id, @name;
54+
select @id
55+
"""));
56+
57+
[TestMethod]
58+
public void CursorRows_Static_ReturnsRowCount()
59+
=> AreEqual(3, ExecuteScalar<int>(Seed + """
60+
declare c cursor static for select id from t;
61+
open c;
62+
select @@cursor_rows
63+
"""));
64+
65+
[TestMethod]
66+
public void CursorRows_Dynamic_ReturnsMinusOne()
67+
=> AreEqual(-1, ExecuteScalar<int>(Seed + """
68+
declare c cursor dynamic for select id from t;
69+
open c;
70+
select @@cursor_rows
71+
"""));
72+
73+
[TestMethod]
74+
public void OpenUndeclaredCursor_RaisesMsg16916()
75+
=> new Simulation().AssertSqlError("open nope", 16916,
76+
"A cursor with the name 'nope' does not exist.");
77+
78+
[TestMethod]
79+
public void DuplicateDeclare_RaisesMsg16915()
80+
=> new Simulation().AssertSqlError(Seed + """
81+
declare dup cursor for select id from t;
82+
declare dup cursor for select id from t;
83+
""", 16915, "A cursor with the name 'dup' already exists.");
84+
85+
[TestMethod]
86+
public void OpenAlreadyOpen_RaisesMsg16905()
87+
=> new Simulation().AssertSqlError(Seed + """
88+
declare c cursor for select id from t;
89+
open c; open c;
90+
""", 16905, "The cursor is already open.");
91+
92+
[TestMethod]
93+
public void CloseNotOpen_RaisesMsg16917()
94+
=> new Simulation().AssertSqlError(Seed + """
95+
declare c cursor for select id from t;
96+
close c;
97+
""", 16917, "Cursor is not open.");
98+
99+
[TestMethod]
100+
public void FetchNotOpen_RaisesMsg16917()
101+
=> new Simulation().AssertSqlError(Seed + """
102+
declare @v int;
103+
declare c cursor for select id from t;
104+
fetch next from c into @v;
105+
""", 16917, "Cursor is not open.");
106+
107+
[TestMethod]
108+
public void FetchIntoColumnCountMismatch_RaisesMsg16924()
109+
=> new Simulation().AssertSqlError(Seed + """
110+
declare @only int;
111+
declare c cursor for select id, name from t;
112+
open c;
113+
fetch next from c into @only;
114+
""", 16924, "Cursorfetch: The number of variables declared in the INTO list must match that of selected columns.");
115+
116+
[TestMethod]
117+
public void DeallocateUndeclared_RaisesMsg16916()
118+
=> new Simulation().AssertSqlError("deallocate nope", 16916,
119+
"A cursor with the name 'nope' does not exist.");
120+
121+
[TestMethod]
122+
public void ScrollFetch_AllDirections()
123+
=> AreEqual("3|1|2|1|3", ExecuteScalar(Seed + """
124+
declare @s int, @log varchar(50) = '';
125+
declare c cursor scroll for select id from t order by id;
126+
open c;
127+
fetch last from c into @s; set @log = convert(varchar,@s);
128+
fetch first from c into @s; set @log = @log + '|' + convert(varchar,@s);
129+
fetch absolute 2 from c into @s; set @log = @log + '|' + convert(varchar,@s);
130+
fetch prior from c into @s; set @log = @log + '|' + convert(varchar,@s);
131+
fetch relative 2 from c into @s; set @log = @log + '|' + convert(varchar,@s);
132+
close c; deallocate c;
133+
select @log
134+
"""));
135+
136+
[TestMethod]
137+
public void ScrollFetchOnForwardOnlyCursor_RaisesMsg16925()
138+
=> new Simulation().AssertSqlError(Seed + """
139+
declare @v int;
140+
declare c cursor forward_only for select id from t;
141+
open c;
142+
fetch absolute 2 from c into @v;
143+
""", 16925, "The fetch type Absolute cannot be used with dynamic cursors.");
144+
145+
[TestMethod]
146+
public void StaticCursor_ImmuneToColumnChangeMidLoop()
147+
=> AreEqual("a;b;c;", new Simulation().ExecuteScalar(Seed + """
148+
declare @id int, @name varchar(20), @log varchar(200) = '';
149+
declare c cursor static for select id, name from t order by id;
150+
open c;
151+
fetch next from c into @id, @name;
152+
update t set name = 'CHANGED' where id = 2;
153+
while @@fetch_status = 0
154+
begin
155+
set @log = @log + @name + ';';
156+
fetch next from c into @id, @name;
157+
end
158+
select @log
159+
"""));
160+
161+
[TestMethod]
162+
public void DynamicCursor_SeesColumnChangeMidLoop()
163+
=> AreEqual("a;CHANGED;c;", new Simulation().ExecuteScalar(Seed + """
164+
declare @id int, @name varchar(20), @log varchar(200) = '';
165+
declare c cursor dynamic for select id, name from t order by id;
166+
open c;
167+
fetch next from c into @id, @name;
168+
update t set name = 'CHANGED' where id = 2;
169+
while @@fetch_status = 0
170+
begin
171+
set @log = @log + @name + ';';
172+
fetch next from c into @id, @name;
173+
end
174+
select @log
175+
"""));
176+
177+
[TestMethod]
178+
public void DynamicCursor_SkipsRowDeletedAhead()
179+
=> AreEqual("1;3;", new Simulation().ExecuteScalar(Seed + """
180+
declare @id int, @name varchar(20), @log varchar(200) = '';
181+
declare c cursor dynamic for select id, name from t order by id;
182+
open c;
183+
fetch next from c into @id, @name;
184+
delete t where id = 2;
185+
while @@fetch_status = 0
186+
begin
187+
set @log = @log + convert(varchar,@id) + ';';
188+
fetch next from c into @id, @name;
189+
end
190+
select @log
191+
"""));
192+
193+
[TestMethod]
194+
public void DynamicCursor_SeesRowInsertedAhead()
195+
=> AreEqual("1;2;3;9;", new Simulation().ExecuteScalar(Seed + """
196+
declare @id int, @name varchar(20), @log varchar(200) = '';
197+
declare c cursor dynamic for select id, name from t order by id;
198+
open c;
199+
fetch next from c into @id, @name;
200+
insert t values (9, 'i');
201+
while @@fetch_status = 0
202+
begin
203+
set @log = @log + convert(varchar,@id) + ';';
204+
fetch next from c into @id, @name;
205+
end
206+
select @log
207+
"""));
208+
209+
[TestMethod]
210+
public void KeysetCursor_SeesColumnChangeButNotInsert()
211+
=> AreEqual("a;K2;c;", new Simulation().ExecuteScalar(Seed + """
212+
declare @id int, @name varchar(20), @log varchar(200) = '';
213+
declare c cursor keyset for select id, name from t order by id;
214+
open c;
215+
fetch next from c into @id, @name;
216+
update t set name = 'K2' where id = 2;
217+
insert t values (9, 'i');
218+
while @@fetch_status = 0
219+
begin
220+
set @log = @log + @name + ';';
221+
fetch next from c into @id, @name;
222+
end
223+
select @log
224+
"""));
225+
226+
[TestMethod]
227+
public void KeysetCursor_DeletedMember_FetchStatusMinusTwo()
228+
=> AreEqual(-2, ExecuteScalar<int>(Seed + """
229+
declare @id int, @name varchar(20);
230+
declare c cursor keyset for select id, name from t order by id;
231+
open c;
232+
fetch next from c into @id, @name; -- id 1
233+
delete t where id = 2;
234+
fetch next from c into @id, @name; -- keyset member 2, now deleted
235+
select @@fetch_status
236+
"""));
237+
238+
[TestMethod]
239+
public void CursorStatus_OpenReturnsOne()
240+
=> AreEqual((short)1, ExecuteScalar<short>(Seed + """
241+
declare c cursor for select id from t;
242+
open c;
243+
select cursor_status('local', 'c')
244+
"""));
245+
246+
[TestMethod]
247+
public void CursorStatus_NonexistentReturnsMinusThree()
248+
=> AreEqual((short)-3, ExecuteScalar<short>("select cursor_status('local', 'ghost')"));
249+
250+
[TestMethod]
251+
public void WhereCurrentOf_UpdatesPositionedRow()
252+
=> AreEqual("POS", new Simulation().ExecuteScalar(Seed + """
253+
declare @id int;
254+
declare c cursor for select id from t order by id;
255+
open c;
256+
fetch next from c into @id; -- on id 1
257+
fetch next from c into @id; -- on id 2
258+
update t set name = 'POS' where current of c;
259+
close c; deallocate c;
260+
select name from t where id = 2
261+
"""));
262+
263+
[TestMethod]
264+
public void WhereCurrentOf_DeletesPositionedRow()
265+
=> AreEqual(0, ExecuteScalar<int>(Seed + """
266+
declare @id int;
267+
declare c cursor for select id from t order by id;
268+
open c;
269+
fetch next from c into @id; -- on id 1
270+
delete from t where current of c;
271+
close c; deallocate c;
272+
select count(*) from t where id = 1
273+
"""));
274+
275+
[TestMethod]
276+
public void WhereCurrentOf_ReadOnlyStaticCursor_RaisesMsg16929()
277+
=> new Simulation().AssertSqlError(Seed + """
278+
declare @id int;
279+
declare c cursor static for select id from t order by id;
280+
open c;
281+
fetch next from c into @id;
282+
update t set name = 'x' where current of c;
283+
""", 16929, "The cursor is READ ONLY.");
284+
285+
[TestMethod]
286+
public void WhereCurrentOf_BeforeAnyFetch_RaisesMsg16931()
287+
=> new Simulation().AssertSqlError(Seed + """
288+
declare c cursor for select id from t order by id;
289+
open c;
290+
update t set name = 'x' where current of c;
291+
""", 16931, "There are no rows in the current fetch buffer.");
292+
293+
[TestMethod]
294+
public void DeallocateWhileOpen_IsAllowed()
295+
=> AreEqual((short)-3, ExecuteScalar<short>(Seed + """
296+
declare c cursor for select id from t;
297+
open c;
298+
deallocate c;
299+
select cursor_status('local', 'c')
300+
"""));
301+
302+
[TestMethod]
303+
public void NonUpdatableQuery_ForcedToStaticEvenWhenDynamicRequested()
304+
=> AreEqual(1, ExecuteScalar<int>(Seed + """
305+
declare c cursor dynamic for select count(*) from t;
306+
open c;
307+
select @@cursor_rows
308+
"""));
309+
310+
[TestMethod]
311+
public void CursorVariable_NotSupported()
312+
=> Throws<NotSupportedException>(() => ExecuteScalar("declare @c cursor"));
313+
314+
[TestMethod]
315+
public void FetchWithoutInto_YieldsSingleRowResultSet()
316+
{
317+
var sim = new Simulation();
318+
_ = sim.ExecuteNonQuery("create table t (id int primary key, name varchar(20)); insert t values (1,'a'),(2,'b')");
319+
using var conn = sim.CreateOpenConnection();
320+
_ = conn.CreateCommand("declare c cursor for select id, name from t order by id; open c").ExecuteNonQuery();
321+
using var reader = conn.CreateCommand("fetch next from c").ExecuteReader();
322+
IsTrue(reader.Read());
323+
AreEqual(1, reader.GetInt32(0));
324+
AreEqual("a", reader.GetString(1));
325+
IsFalse(reader.Read());
326+
}
327+
}

0 commit comments

Comments
 (0)