Skip to content

Commit dc659a6

Browse files
committed
Cursors gain variables, true scoping, concurrency semantics, and the sp_cursor* API-server-cursor RPC family: DECLARE @c CURSOR / SET-binding shares refcounted cursor objects with position, CURSOR VARYING OUTPUT params survive into the caller, GLOBAL/LOCAL become distinct scopes with LOCAL-first resolution and the scope-aware CURSOR_STATUS matrix, SCROLL_LOCKS holds a U row lock across autocommit until scroll-away/close, OPTIMISTIC positioned DML raises 16947/16934/3621 chain, FOR UPDATE OF enforces Msg 16932 and TYPE_WARNING emits Msg 16956; over TDS, procids 1-9 dispatch to handlers that reuse the engine cursors end-to-end, mirroring the probed wire contract. Trailing ROWSTAT column, metadata-only open, INPUT-only fetch positioning, scrollopt/ccopt downgrade OUT values, rowcount -1 for dynamic, and real's own non-surfacing of optimistic conflicts through sp_cursor.
1 parent 92e5671 commit dc659a6

26 files changed

Lines changed: 2276 additions & 136 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
130130
- **`PIVOT` / `UNPIVOT`** — PIVOT desugars to grouped conditional aggregation (implicit grouping = inner columns minus FOR + aggregate-arg), UNPIVOT is a NULL-skipping unfold; both attach as a postfix FROM-source wrapper on the derived-table `LateralPlan` seam → [`pivot.md`](docs/claude/pivot.md).
131131
- **UPDATE / DELETE / INSERT…SELECT / SELECT…INTO / rowversion** (`@@DBTS` / `MIN_ACTIVE_ROWVERSION`) **/ identity helpers** (`@@IDENTITY` / `SCOPE_IDENTITY` / `IDENT_CURRENT`/`_INCR`/`_SEED`) **/ `@@ROWCOUNT` / `ROWCOUNT_BIG` / OUTPUT / MERGE**[`dml.md`](docs/claude/dml.md).
132132
- **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).
133-
- **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).
133+
- **Cursors** (`DECLARE … CURSOR` / `OPEN` / `FETCH` / `CLOSE` / `DEALLOCATE`, STATIC / KEYSET / DYNAMIC sensitivity, scroll fetches, `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS`, `WHERE CURRENT OF`; GLOBAL / LOCAL scope + scope-aware resolution, cursor variables (`DECLARE @c CURSOR` / `SET @c = CURSOR …` refcounted, cursor OUTPUT proc params), `FOR UPDATE OF` gating (Msg 16932), SCROLL_LOCKS cursor-scoped U locks + OPTIMISTIC conflict detection (Msg 16947/16934), TYPE_WARNING (Msg 16956)) → [`cursors.md`](docs/claude/cursors.md).
134134
- **CTE shapes / recursive-CTE error handling**[`ctes.md`](docs/claude/ctes.md).
135135
- **JSON** (JSON_VALUE / JSON_QUERY / JSON_MODIFY / JSON_OBJECT / JSON_ARRAY / JSON_OBJECTAGG / JSON_ARRAYAGG / JSON_PATH_EXISTS / ISJSON / OPENJSON) → [`json.md`](docs/claude/json.md).
136136
- **Name resolution, schema lookup, CREATE / DROP / ALTER SCHEMA TRANSFER, `OBJECT_ID`/`_NAME`/`_SCHEMA_NAME` / `SCHEMA_ID`/`_NAME` / `DB_ID`/`_NAME`, cross-DB reads**[`schemas.md`](docs/claude/schemas.md).
@@ -164,7 +164,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
164164
- **New top-level statement parser or dispatch-loop separator rules, double-quoted identifiers / QUOTED_IDENTIFIER**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
165165
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
166166
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
167-
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM, no bulk; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
167+
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM, no bulk; `sp_cursor*` API-server-cursor RPC family; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
168168

169169
## Not modeled
170170

SqlServerSimulator.Tests.SqlClient/CursorRpcTests.cs

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

SqlServerSimulator.Tests/CursorBreadthTests.cs

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

SqlServerSimulator.Tests/CursorTests.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ public void CursorStatus_OpenReturnsOne()
240240
=> AreEqual((short)1, ExecuteScalar<short>(Seed + """
241241
declare c cursor for select id from t;
242242
open c;
243-
select cursor_status('local', 'c')
243+
select cursor_status('global', 'c')
244244
"""));
245245

246246
[TestMethod]
@@ -307,9 +307,13 @@ public void NonUpdatableQuery_ForcedToStaticEvenWhenDynamicRequested()
307307
select @@cursor_rows
308308
"""));
309309

310+
/// <summary>
311+
/// Cursor variables are modeled — see CursorBreadthTests for the full
312+
/// lifecycle. A bare DECLARE registers an unallocated slot.
313+
/// </summary>
310314
[TestMethod]
311-
public void CursorVariable_NotSupported()
312-
=> Throws<NotSupportedException>(() => ExecuteScalar("declare @c cursor"));
315+
public void CursorVariable_DeclareRegistersUnallocatedSlot()
316+
=> AreEqual((short)-2, ExecuteScalar<short>("declare @c cursor; select cursor_status('variable','@c')"));
313317

314318
[TestMethod]
315319
public void JoinCursor_ForwardLoopOverMultipleSources()

SqlServerSimulator/Cursor.cs

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ internal enum CursorSensitivity
2929
/// forward-only cursor; the rest require a scrollable cursor.</summary>
3030
internal enum FetchDirection { Next, Prior, First, Last, Absolute, Relative }
3131

32+
/// <summary>
33+
/// The concurrency-control model of an updatable cursor (the
34+
/// <c>READ_ONLY</c> / <c>SCROLL_LOCKS</c> / <c>OPTIMISTIC</c> keyword family).
35+
/// A read-only cursor carries <see cref="Cursor.ReadOnly"/> instead.
36+
/// </summary>
37+
internal enum CursorConcurrency
38+
{
39+
/// <summary>Default optimistic-without-detection: positioned DML just
40+
/// re-locates the row and rewrites it (the pre-existing behavior).</summary>
41+
Default,
42+
43+
/// <summary><c>SCROLL_LOCKS</c>: a U lock is held on the currently-fetched
44+
/// row (cursor-scoped — released when the cursor scrolls off the row,
45+
/// closes, or deallocates), and positioned DML upgrades it to X.</summary>
46+
ScrollLocks,
47+
48+
/// <summary><c>OPTIMISTIC</c>: no lock is held; positioned DML re-reads the
49+
/// row and raises the optimistic-conflict chain (Msg 16947 / 16934) when it
50+
/// was modified out-of-band since the fetch.</summary>
51+
Optimistic,
52+
}
53+
3254
/// <summary>
3355
/// A session-scoped T-SQL cursor (declared with <c>DECLARE … CURSOR FOR
3456
/// &lt;select&gt;</c>). Lives in <see cref="SimulatedDbConnection.Cursors"/>.
@@ -44,7 +66,9 @@ internal sealed class Cursor(
4466
CursorSensitivity sensitivity,
4567
bool scrollable,
4668
bool readOnly,
47-
HeapTable? baseTable)
69+
HeapTable? baseTable,
70+
CursorConcurrency concurrency = CursorConcurrency.Default,
71+
List<string>? forUpdateColumns = null)
4872
{
4973
public readonly string Name = name;
5074
public readonly Selection Selection = selection;
@@ -55,8 +79,51 @@ internal sealed class Cursor(
5579
/// <summary>The single base table, non-null for KEYSET / DYNAMIC / positioned DML.</summary>
5680
public readonly HeapTable? BaseTable = baseTable;
5781

82+
/// <summary>The concurrency model — <see cref="CursorConcurrency.ScrollLocks"/>
83+
/// holds a cursor-scoped U lock on the fetched row, <see cref="CursorConcurrency.Optimistic"/>
84+
/// detects out-of-band modification at positioned DML time.</summary>
85+
public readonly CursorConcurrency Concurrency = concurrency;
86+
87+
/// <summary>The <c>FOR UPDATE OF (col, …)</c> column list (surface names),
88+
/// or null when the cursor was declared <c>FOR UPDATE</c> without an OF list
89+
/// (every column updatable) or without a FOR UPDATE clause. A positioned
90+
/// UPDATE of a column absent from a non-null list raises Msg 16932.</summary>
91+
public readonly List<string>? ForUpdateColumns = forUpdateColumns;
92+
93+
/// <summary>
94+
/// Reference count of cursor variables (<c>DECLARE @c CURSOR</c>) pointing
95+
/// at this object. A named cursor sits at 0; each <c>SET @c = …</c> binding
96+
/// increments, each <c>DEALLOCATE @c</c> decrements, and the object is torn
97+
/// down only when the count returns to 0 with no name. Matches SQL Server's
98+
/// refcounted cursor-variable model (probe-confirmed).
99+
/// </summary>
100+
public int VariableRefCount;
101+
102+
/// <summary>True for an unnamed cursor that exists only through cursor
103+
/// variables (created by <c>SET @c = CURSOR FOR …</c>); such a cursor is
104+
/// destroyed when its last variable reference is deallocated.</summary>
105+
public bool IsUnnamed;
106+
58107
public bool IsOpen;
59108

109+
/// <summary>
110+
/// OPTIMISTIC snapshot of the currently-fetched row's full stored bytes,
111+
/// captured at each FETCH. Positioned DML compares the live bytes at
112+
/// <see cref="CurrentRid"/> against this; any difference (a value change, a
113+
/// rowversion bump, or the row's disappearance) is an optimistic conflict.
114+
/// A full-row byte compare subsumes both real detection bases — rowversion
115+
/// column when present, column checksum otherwise. Null when the cursor
116+
/// isn't OPTIMISTIC or isn't on a live row.
117+
/// </summary>
118+
private byte[]? optimisticSnapshot;
119+
120+
// SCROLL_LOCKS: cursor-scoped locks held directly (not through the
121+
// statement / transaction release lists). The table-IX is held for the
122+
// cursor's open lifetime; the row-U follows the current fetch position and
123+
// is released when the cursor scrolls off the row, closes, or deallocates.
124+
private LockResource? scrollTableLock;
125+
private LockResource? scrollRowLock;
126+
60127
/// <summary>
61128
/// The value <c>CURSOR_STATUS</c> reports for this (existing) cursor:
62129
/// <c>-1</c> closed, <c>1</c> open DYNAMIC or open with ≥1 row, <c>0</c>
@@ -119,22 +186,115 @@ public void Open(BatchContext batch)
119186

120187
this.CurrentRid = null;
121188
this.IsOpen = true;
189+
190+
// SCROLL_LOCKS: take table-IX for the cursor's open lifetime (the
191+
// per-row U locks ride the fetch position). Held cursor-scoped, so it
192+
// outlives individual statements and any autocommit boundary — matching
193+
// real SQL Server, where scroll locks persist while the cursor is
194+
// positioned regardless of an enclosing transaction.
195+
if (this.Concurrency == CursorConcurrency.ScrollLocks && this.BaseTable is { } table2)
196+
{
197+
var connection = batch.Connection;
198+
connection.Simulation.LockManager.Acquire(table2.TableDataLock, LockMode.IntentExclusive, connection, connection.LockTimeoutMillis);
199+
this.scrollTableLock = table2.TableDataLock;
200+
}
122201
}
123202

124203
/// <summary>CLOSE the cursor: release the materialized state and reset
125204
/// position. The cursor stays declared (re-OPEN-able). Raises Msg 16917
126205
/// (state 1) if not open.</summary>
127-
public void Close()
206+
public void Close(BatchContext batch)
128207
{
129208
if (!this.IsOpen)
130209
throw SimulatedSqlException.CursorNotOpen(state: 1);
210+
this.ReleaseScrollLocks(batch.Connection);
131211
this.staticRows = null;
132212
this.keysetIdentities = null;
133213
this.dynamicLast = null;
134214
this.CurrentRid = null;
215+
this.optimisticSnapshot = null;
135216
this.IsOpen = false;
136217
}
137218

219+
/// <summary>
220+
/// Releases both cursor-scoped SCROLL_LOCKS locks (the position-following
221+
/// row-U and the open-lifetime table-IX). Called on CLOSE, on the last
222+
/// DEALLOCATE, and at connection dispose. No-op for non-SCROLL_LOCKS
223+
/// cursors.
224+
/// </summary>
225+
internal void ReleaseScrollLocks(SimulatedDbConnection connection)
226+
{
227+
var lockManager = connection.Simulation.LockManager;
228+
if (this.scrollRowLock is { } row)
229+
{
230+
lockManager.Release(row, LockMode.Update, connection);
231+
this.scrollRowLock = null;
232+
}
233+
if (this.scrollTableLock is { } tbl)
234+
{
235+
lockManager.Release(tbl, LockMode.IntentExclusive, connection);
236+
this.scrollTableLock = null;
237+
}
238+
}
239+
240+
/// <summary>
241+
/// Moves the SCROLL_LOCKS row-U lock onto the freshly-fetched row: releases
242+
/// the row we scrolled off (if any) and acquires U on
243+
/// <see cref="CurrentRid"/>. A concurrent writer of the current row then
244+
/// blocks (U conflicts with the writer's X); scrolling away frees it.
245+
/// </summary>
246+
private void MoveScrollLock(BatchContext batch)
247+
{
248+
if (this.BaseTable is not { } table)
249+
return;
250+
var connection = batch.Connection;
251+
var lockManager = connection.Simulation.LockManager;
252+
if (this.scrollRowLock is { } prior)
253+
{
254+
lockManager.Release(prior, LockMode.Update, connection);
255+
this.scrollRowLock = null;
256+
}
257+
if (this.CurrentRid is { } rid)
258+
{
259+
var resource = table.GetOrCreateRowLock(rid.Page, rid.Slot);
260+
lockManager.Acquire(resource, LockMode.Update, connection, connection.LockTimeoutMillis);
261+
this.scrollRowLock = resource;
262+
}
263+
}
264+
265+
/// <summary>
266+
/// For an <see cref="CursorConcurrency.Optimistic"/> cursor, raises the
267+
/// optimistic-conflict chain (Msg 16947 / 16934) when the current row's
268+
/// live bytes differ from the snapshot captured at FETCH — a value change,
269+
/// a rowversion bump, or the row's deletion out-of-band. No-op for other
270+
/// concurrency modes. Called at positioned UPDATE / DELETE time.
271+
/// </summary>
272+
internal void CheckOptimisticConflict()
273+
{
274+
if (this.Concurrency != CursorConcurrency.Optimistic || this.BaseTable is not { } table)
275+
return;
276+
var current = this.CurrentRid is { } rid ? table.Heap.ReadSlotBytes(rid.Page, rid.Slot) : null;
277+
if (current is null || this.optimisticSnapshot is null || !current.AsSpan().SequenceEqual(this.optimisticSnapshot))
278+
throw SimulatedSqlException.CursorOptimisticConflict();
279+
}
280+
281+
/// <summary>
282+
/// True when <paramref name="column"/> may be updated through a positioned
283+
/// <c>WHERE CURRENT OF</c>: always true unless the cursor carries a
284+
/// <c>FOR UPDATE OF (…)</c> column list that omits it (Msg 16932).
285+
/// </summary>
286+
internal bool IsColumnUpdatable(string column, BatchContext batch)
287+
{
288+
if (this.ForUpdateColumns is null)
289+
return true;
290+
foreach (var allowed in this.ForUpdateColumns)
291+
{
292+
if (batch.CurrentDatabase.Collation.Equals(allowed, column))
293+
return true;
294+
}
295+
return false;
296+
}
297+
138298
/// <summary>
139299
/// FETCH one row in the requested direction. Returns the SQL Server
140300
/// <c>@@FETCH_STATUS</c> (0 success, -1 past end / no row, -2 keyset member
@@ -146,12 +306,25 @@ public void Close()
146306
if (!this.IsOpen)
147307
throw SimulatedSqlException.CursorNotOpen(state: 2);
148308
this.EnsureDirectionAllowed(direction);
149-
return this.Sensitivity switch
309+
var result = this.Sensitivity switch
150310
{
151311
CursorSensitivity.Static => this.FetchStatic(direction, offset),
152312
CursorSensitivity.Keyset => this.FetchKeyset(batch, direction, offset),
153313
_ => this.FetchDynamic(batch, direction),
154314
};
315+
316+
// OPTIMISTIC: snapshot the landed row's live bytes so a later positioned
317+
// UPDATE / DELETE can detect out-of-band modification. SCROLL_LOCKS:
318+
// move the cursor-scoped U lock onto the newly-fetched row (releasing
319+
// the row we scrolled off), so a concurrent writer of the current row
320+
// blocks. Both are no-ops when the fetch didn't land on a live row.
321+
this.optimisticSnapshot = this.Concurrency == CursorConcurrency.Optimistic && this.CurrentRid is { } orid
322+
? this.BaseTable?.Heap.ReadSlotBytes(orid.Page, orid.Slot)
323+
: null;
324+
if (this.Concurrency == CursorConcurrency.ScrollLocks)
325+
this.MoveScrollLock(batch);
326+
327+
return result;
155328
}
156329

157330
/// <summary>

SqlServerSimulator/Errors/SimulatedSqlException.CursorErrors.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,40 @@ internal static SimulatedSqlException CursorIsReadOnly() =>
6767
/// </summary>
6868
internal static SimulatedSqlException CursorNoCurrentRow() =>
6969
new("There are no rows in the current fetch buffer.", 16931, 16, 1);
70+
71+
/// <summary>
72+
/// Msg 16932: a positioned <c>UPDATE … WHERE CURRENT OF</c> assigns a
73+
/// column that isn't in the cursor's <c>FOR UPDATE OF (…)</c> list.
74+
/// Probe-confirmed verbatim against SQL Server 2025 (state 1).
75+
/// </summary>
76+
internal static SimulatedSqlException CursorColumnNotInForUpdateList() =>
77+
new("The cursor has a FOR UPDATE list and the requested column to be updated is not in this list.", 16932, 16, 1);
78+
79+
/// <summary>
80+
/// A positioned <c>UPDATE</c> / <c>DELETE WHERE CURRENT OF</c> on an
81+
/// <c>OPTIMISTIC</c> cursor found the current row modified (or deleted)
82+
/// out-of-band since it was fetched. Real SQL Server surfaces a three-error
83+
/// chain (probe-confirmed against SQL Server 2025): the terminating
84+
/// <b>Msg 16947</b> (class 16, state 1, <c>"No rows were updated or
85+
/// deleted."</c>) — the number a SqlClient consumer catches — followed by
86+
/// the descriptive class-0 <b>Msg 16934</b> and the standard <b>Msg
87+
/// 3621</b> statement-terminated companion. The full chain is reproduced so
88+
/// <c>SqlException.Errors</c> and <c>.Message</c> match.
89+
/// </summary>
90+
internal static SimulatedSqlException CursorOptimisticConflict() =>
91+
new(
92+
"No rows were updated or deleted.\nOptimistic concurrency check failed. The row was modified outside of this cursor.\nThe statement has been terminated.",
93+
new SimulatedError(@class: 16, lineNumber: 0, "No rows were updated or deleted.", 16947, procedure: "", server: "", source: "Core Microsoft SqlClient Data Provider", state: 1),
94+
new SimulatedError(@class: 0, lineNumber: 0, "Optimistic concurrency check failed. The row was modified outside of this cursor.", 16934, procedure: "", server: "", source: "Core Microsoft SqlClient Data Provider", state: 1),
95+
new SimulatedError(@class: 0, lineNumber: 0, "The statement has been terminated.", 3621, procedure: "", server: "", source: "Core Microsoft SqlClient Data Provider", state: 0));
96+
97+
/// <summary>
98+
/// Msg 16950: a <c>FETCH</c> (or other cursor operation) named a cursor
99+
/// <em>variable</em> that has no cursor allocated to it — declared with
100+
/// <c>DECLARE @c CURSOR</c> but never <c>SET</c>, or referencing a
101+
/// deallocated cursor. Probe-confirmed verbatim against SQL Server 2025
102+
/// (class 16, state 2).
103+
/// </summary>
104+
internal static SimulatedSqlException CursorVariableNotAllocated(string variableName) =>
105+
new($"The variable '@{variableName}' does not currently have a cursor allocated to it.", 16950, 16, 2);
70106
}

0 commit comments

Comments
 (0)