Skip to content

Commit 6eee435

Browse files
committed
Improved fidelity of cursors when there's no unique key.
1 parent 38df298 commit 6eee435

15 files changed

Lines changed: 622 additions & 215 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Three entry points share one per-connection undo log: implicit (statement-level
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

130130
### 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).
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) → STATIC; STATIC frozen-snapshot, KEYSET frozen-membership with live value re-reads (deleted-or-key-changed member → `@@FETCH_STATUS=-2`), DYNAMIC fully live (skips deletes, sees inserts, `@@CURSOR_ROWS=-1`). Position tracked by the row's stable `(page, slot)` heap address (preserved through UPDATEs via `Heap.UpdateAt`'s in-place / forwarding-pointer machinery); KEYSET additionally tracks the unique-key tuple when the base table has a PK/UNIQUE, so a unique-column UPDATE produces -2. 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).
132132

133133
### EF Core adapter coverage
134134
`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`.
@@ -213,8 +213,8 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
213213
- `decimal` / `numeric`: backed by .NET `decimal`. Values requiring more than 28 significant digits aren't modeled (declarations up through `decimal(38, *)` accepted so storage byte-width matches).
214214
- `float` text formatting: .NET `G15`/`G7` rather than SQL Server's `1e+015`-style scientific.
215215
- Auto-generated constraint names: PK / UNIQUE shape `PK__<table8>__<16hex>` / `UQ__<table8>__<16hex>` (16-hex 64-bit FNV-1a); CK / FK / DF shape `CK__<table8>__[<col8>__]<8hex>` (8-hex 32-bit FNV-1a). Both are deterministic across runs, distinct from SQL Server's object-id-derived hex (so won't byte-match).
216-
- **DELETE / UPDATE leak page space**: deleted (or relocated) row payload bytes stay in their original page until process exit; only the slot is tombstoned. Slot directory entries also never reused.
217-
- **DELETE / UPDATE leak LOB chains**: orphaned LOB chains stay in `Heap.LobPages`. Other rows reference LOB pages by stable index, so list compaction would corrupt them.
216+
- **DELETE / forwarding UPDATE leak page space**: deleted row payload bytes stay in their original page until process exit; only the slot is tombstoned. Slot directory entries also never reused. An UPDATE whose new payload exceeds the slot's existing extent forwards (single-level pointer matching SQL Server's heap-update behavior) and similarly leaves the original slot's payload bytes dead. An UPDATE that fits in place reuses the slot's bytes and adds no leak.
217+
- **UPDATE leaks LOB chains**: a UPDATE-replaced row's LOB chains stay in `Heap.LobPages`; the row encoder allocates fresh chains for the new payload regardless of whether the UPDATE rewrites in place or forwards. DELETE leaks similarly. Other rows reference LOB pages by stable index, so list compaction would corrupt them.
218218
- **`GetBytes`/`GetChars` materialize, don't stream**: each call decodes the full column value via `RowDecoder` and slices into the caller's buffer. Behavior matches per-call observation; the streaming-memory guarantee doesn't.
219219
- **`SELECT INTO` string `+` reads as nullable**: real SQL Server projects `cs + 'x'` (both NOT NULL) as NOT NULL; the simulator can't statically distinguish string-concat from integer-add at projection-schema time (the dispatch happens runtime on operand types), so all `Add` results read as nullable. Conservative; no test reliance on string-`+`-non-null.
220220
- **`SELECT INTO` from a CTE drops identity + nullability**: CTE bindings synthesize their wrapper `HeapColumn` entries with `nullable: true` and no identity, so the analyzer treats CTE sources as derived plans. Real SQL Server preserves both through simple single-source CTEs. Fix requires propagating column metadata through CTE bindings.
@@ -230,4 +230,4 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
230230
- **`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.
231231
- **`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.
232232
- **`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.
233-
- **Cursor edges**: cursors over a **JOIN / derived table / view** are forced to STATIC (read-only snapshot, `@@CURSOR_ROWS`=count); real SQL Server makes them DYNAMIC (`@@CURSOR_ROWS`=-1, sees mid-loop changes, `WHERE CURRENT OF` works) — the simulator only takes the updatable KEYSET/DYNAMIC path for a *direct single base table with a PK/UNIQUE* (set-op/UNION cursors are forced STATIC on the real server too, so those match). `@@CURSOR_ROWS` is `-1` throughout for DYNAMIC (real 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. See [`docs/claude/cursors.md`](docs/claude/cursors.md).
233+
- **Cursor edges**: cursors over a **JOIN / derived table / view** are forced to STATIC (read-only snapshot, `@@CURSOR_ROWS`=count); real SQL Server makes them DYNAMIC (`@@CURSOR_ROWS`=-1, sees mid-loop changes, `WHERE CURRENT OF` works) — the simulator only takes the updatable KEYSET/DYNAMIC path for a *direct single base table* (set-op/UNION cursors are forced STATIC on the real server too, so those match). `@@CURSOR_ROWS` is `-1` throughout for DYNAMIC (real 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. See [`docs/claude/cursors.md`](docs/claude/cursors.md).

SqlServerSimulator.Tests/CursorTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,69 @@ select @log
556556
"""));
557557
}
558558

559+
private const string NoKeySeed =
560+
"create table h (id int not null, name varchar(20) not null); " +
561+
"insert h values (1,'a'),(2,'b'),(3,'c');";
562+
563+
[TestMethod]
564+
public void KeysetCursor_NoUniqueKeyHeap_SeesUpdatedValues()
565+
=> AreEqual("a;NEW;c;", new Simulation().ExecuteScalar(NoKeySeed + """
566+
declare @id int, @name varchar(20), @log varchar(200) = '';
567+
declare c cursor keyset for select id, name from h order by id;
568+
open c; fetch next from c into @id, @name;
569+
update h set name = 'NEW' where id = 2; -- non-key UPDATE on no-key heap
570+
while @@fetch_status = 0
571+
begin
572+
set @log = @log + @name + ';';
573+
fetch next from c into @id, @name;
574+
end
575+
close c; deallocate c;
576+
select @log
577+
"""));
578+
579+
[TestMethod]
580+
public void WhereCurrentOf_NoUniqueKeyHeap_UpdatesPositionedRow()
581+
=> AreEqual("POS", new Simulation().ExecuteScalar(NoKeySeed + """
582+
declare @id int;
583+
declare c cursor for select id from h order by id;
584+
open c;
585+
fetch next from c into @id; -- id 1
586+
fetch next from c into @id; -- id 2 (positioned)
587+
update h set name = 'POS' where current of c;
588+
close c; deallocate c;
589+
select name from h where id = 2
590+
"""));
591+
592+
[TestMethod]
593+
public void WhereCurrentOf_NoUniqueKeyHeap_DeletesPositionedRow()
594+
=> AreEqual(2, ExecuteScalar<int>(NoKeySeed + """
595+
declare @id int;
596+
declare c cursor for select id from h order by id;
597+
open c;
598+
fetch next from c into @id;
599+
fetch next from c into @id;
600+
delete h where current of c;
601+
close c; deallocate c;
602+
select count(*) from h
603+
"""));
604+
605+
/// <summary>
606+
/// Force the UPDATE to forward (new name longer than old) — the cursor
607+
/// must still find the row by its stable address on re-fetch.
608+
/// </summary>
609+
[TestMethod]
610+
public void ForwardedUpdate_PreservesRowAddressForCursorRefetch()
611+
=> AreEqual("AAA", new Simulation().ExecuteScalar(NoKeySeed + """
612+
declare @id int, @name varchar(20);
613+
declare c cursor keyset for select id, name from h order by id;
614+
open c;
615+
fetch next from c into @id, @name; -- id 1
616+
update h set name = replicate('A', 20) where id = 1; -- grows the row → forward
617+
update h set name = 'AAA' where id = 1; -- second update on the same (now-forwarded) row
618+
fetch first from c into @id, @name;
619+
select @name
620+
"""));
621+
559622
[TestMethod]
560623
public void FetchWithoutInto_YieldsSingleRowResultSet()
561624
{

SqlServerSimulator/Cursor.cs

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,19 @@ internal enum FetchDirection { Next, Prior, First, Last, Absolute, Relative }
3232
/// <summary>
3333
/// A session-scoped T-SQL cursor (declared with <c>DECLARE … CURSOR FOR
3434
/// &lt;select&gt;</c>). Lives in <see cref="SimulatedDbConnection.Cursors"/>.
35-
/// Position is tracked by the base table's unique-key tuple (not a physical
36-
/// RID, since the simulator's UPDATE relocates rows), so KEYSET re-reads and
37-
/// positioned <c>WHERE CURRENT OF</c> DML survive value updates.
35+
/// Position is tracked by the base row's stable <c>(page, slot)</c> address,
36+
/// which <see cref="Heap.UpdateAt"/> preserves through value updates by
37+
/// rewriting in place (or installing a forwarding pointer) — so KEYSET
38+
/// membership tracking and positioned <c>WHERE CURRENT OF</c> DML work
39+
/// without requiring the base table to have a unique key.
3840
/// </summary>
3941
internal sealed class Cursor(
4042
string name,
4143
Selection selection,
4244
CursorSensitivity sensitivity,
4345
bool scrollable,
4446
bool readOnly,
45-
HeapTable? baseTable,
46-
int[]? keyStorageOrdinals)
47+
HeapTable? baseTable)
4748
{
4849
public readonly string Name = name;
4950
public readonly Selection Selection = selection;
@@ -54,9 +55,6 @@ internal sealed class Cursor(
5455
/// <summary>The single base table, non-null for KEYSET / DYNAMIC / positioned DML.</summary>
5556
public readonly HeapTable? BaseTable = baseTable;
5657

57-
/// <summary>Storage ordinals of the base table's unique key; non-null with <see cref="BaseTable"/>.</summary>
58-
public readonly int[]? KeyStorageOrdinals = keyStorageOrdinals;
59-
6058
public bool IsOpen;
6159

6260
/// <summary>
@@ -69,17 +67,21 @@ internal sealed class Cursor(
6967
? -1
7068
: this.Sensitivity == CursorSensitivity.Dynamic
7169
? 1
72-
: (this.staticRows?.Count ?? this.keysetKeys?.Count ?? 0) > 0 ? 1 : 0;
70+
: (this.staticRows?.Count ?? this.keysetIdentities?.Count ?? 0) > 0 ? 1 : 0;
7371

74-
/// <summary>Unique-key tuple of the row the cursor is positioned on, or
75-
/// null when not on a live row (before first FETCH, past the end, or on a
72+
/// <summary>Stable <c>(page, slot)</c> address of the row the cursor is positioned
73+
/// on, or null when not on a live row (before first FETCH, past the end, or on a
7674
/// keyset hole). Read by positioned <c>WHERE CURRENT OF</c> DML.</summary>
77-
public SqlValue[]? CurrentKey;
75+
public (int Page, int Slot)? CurrentRid;
7876

7977
// STATIC: frozen projected values, walked by index.
8078
private List<SqlValue[]>? staticRows;
81-
// KEYSET: ordered snapshot of unique-key tuples (membership frozen at OPEN).
82-
private List<SqlValue[]>? keysetKeys;
79+
// KEYSET: ordered snapshot of row identities (membership frozen at OPEN).
80+
// UniqueKey carries the tuple when the base table has a PK/UNIQUE — KEYSET
81+
// membership tracks by that, matching SQL Server's keyset-is-identified-by-
82+
// the-unique-index behavior. UniqueKey null falls back to Rid (no-unique-
83+
// key heap path; simulator extension).
84+
private List<(SqlValue[]? UniqueKey, (int Page, int Slot) Rid)>? keysetIdentities;
8385
// Position for indexed (STATIC / KEYSET): -1 before-first, == count after-last.
8486
private int position;
8587

@@ -103,9 +105,9 @@ public void Open(BatchContext batch)
103105
batch.Connection.LastCursorRows = this.staticRows.Count;
104106
break;
105107
case CursorSensitivity.Keyset:
106-
this.keysetKeys = [.. this.Selection.EnumerateForCursor(batch).Select(r => r.UniqueKey)];
108+
this.keysetIdentities = [.. this.Selection.EnumerateForCursor(batch).Select(r => (r.UniqueKey, r.Rid))];
107109
this.position = -1;
108-
batch.Connection.LastCursorRows = this.keysetKeys.Count;
110+
batch.Connection.LastCursorRows = this.keysetIdentities.Count;
109111
break;
110112
default: // Dynamic
111113
this.dynamicLast = null;
@@ -115,7 +117,7 @@ public void Open(BatchContext batch)
115117
break;
116118
}
117119

118-
this.CurrentKey = null;
120+
this.CurrentRid = null;
119121
this.IsOpen = true;
120122
}
121123

@@ -127,9 +129,9 @@ public void Close()
127129
if (!this.IsOpen)
128130
throw SimulatedSqlException.CursorNotOpen(state: 1);
129131
this.staticRows = null;
130-
this.keysetKeys = null;
132+
this.keysetIdentities = null;
131133
this.dynamicLast = null;
132-
this.CurrentKey = null;
134+
this.CurrentRid = null;
133135
this.IsOpen = false;
134136
}
135137

@@ -171,33 +173,38 @@ private void EnsureDirectionAllowed(FetchDirection direction)
171173
var count = this.staticRows!.Count;
172174
if (!this.TryMoveIndex(direction, offset, count))
173175
{
174-
this.CurrentKey = null;
176+
this.CurrentRid = null;
175177
return (-1, null);
176178
}
177-
// STATIC is read-only; CurrentKey stays null (WHERE CURRENT OF rejected upstream).
179+
// STATIC is read-only; CurrentRid stays null (WHERE CURRENT OF rejected upstream).
178180
return (0, this.staticRows[this.position]);
179181
}
180182

181183
private (int, SqlValue[]?) FetchKeyset(BatchContext batch, FetchDirection direction, long offset)
182184
{
183-
var count = this.keysetKeys!.Count;
185+
var count = this.keysetIdentities!.Count;
184186
if (!this.TryMoveIndex(direction, offset, count))
185187
{
186-
this.CurrentKey = null;
188+
this.CurrentRid = null;
187189
return (-1, null);
188190
}
189191

190-
var key = this.keysetKeys[this.position];
192+
var (key, rid) = this.keysetIdentities[this.position];
191193
foreach (var row in this.Selection.EnumerateForCursor(batch))
192194
{
193-
if (Selection.CompareKeyTuples(row.UniqueKey, key) == 0)
195+
var match = key is not null
196+
? row.UniqueKey is not null && Selection.CompareKeyTuples(row.UniqueKey, key) == 0
197+
: row.Rid.Equals(rid);
198+
if (match)
194199
{
195-
this.CurrentKey = key;
200+
this.CurrentRid = row.Rid;
196201
return (0, row.Values);
197202
}
198203
}
199-
// Member deleted out from under the keyset: status -2, no current row.
200-
this.CurrentKey = null;
204+
// Member deleted out from under the keyset (or its unique-key columns
205+
// changed, making the row no longer findable by the snapshotted key):
206+
// status -2, no current row.
207+
this.CurrentRid = null;
201208
return (-2, null);
202209
}
203210

@@ -245,14 +252,14 @@ private bool TryMoveIndex(FetchDirection direction, long offset, int count)
245252

246253
if (target is null)
247254
{
248-
this.CurrentKey = null;
255+
this.CurrentRid = null;
249256
return (-1, null);
250257
}
251258

252259
this.dynamicLast = target;
253260
this.dynamicBeforeFirst = false;
254261
this.dynamicAfterLast = false;
255-
this.CurrentKey = target.UniqueKey;
262+
this.CurrentRid = target.Rid;
256263
return (0, target.Values);
257264
}
258265

0 commit comments

Comments
 (0)