Skip to content

Commit dd4ba5f

Browse files
committed
Added the rowversion / timestamp type (auto-generated 8-byte database-scoped counter; auto-bumped on every INSERT and UPDATE; explicit values rejected via Msg 273 / 272; second-per-table rejected via Msg 2738) and extended OUTPUT on UPDATE / DELETE to resolve INSERTED.<col> and DELETED.<col> per row, closing the EF Core [Timestamp] optimistic-concurrency round-trip.
1 parent b42f4b7 commit dd4ba5f

16 files changed

Lines changed: 705 additions & 103 deletions

CLAUDE.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,20 @@ Top-level OFFSET/FETCH (post-set-op chain) attaches alongside the top-level ORDE
163163
### UPDATE / DELETE
164164
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`. Single-table only.
165165
- **Multi-column SET evaluates RHS against the pre-update row snapshot** — verified: `UPDATE t SET a = 100, b = a + 1` over `(a=10, b=20)` produces `(a=100, b=11)` (b read pre-update a). Scalar subquery RHS sees the pre-update table state.
166-
- Identity-column update → **Msg 8102** `"Cannot update identity column 'X'."`. Computed-column update → Msg 271 (existing factory).
166+
- Identity-column update → **Msg 8102** `"Cannot update identity column 'X'."`. Computed-column update → Msg 271 (existing factory). Rowversion update → **Msg 272** `"Cannot update a timestamp column."`.
167167
- Per-row constraint re-validation: NOT NULL → **Msg 515** with `"UPDATE fails."` verb; CHECK → **Msg 547** with `"UPDATE statement"` verb. PK / UNIQUE → Msg 2627 (same wording as INSERT — verbatim SQL Server quirk: "Cannot insert duplicate key" wording even on UPDATE).
168168
- Two-phase execution: phase 1 picks affected rows + computes new values + per-row validation; phase 2 validates PK / UNIQUE against the post-update virtual state (other affected rows' new keys + non-affected heap rows' existing keys); phase 3 mutates (tombstone old, insert new).
169-
- Literal-only `OUTPUT` clause on UPDATE / DELETE: `OUTPUT 1` and similar literal / parameter projections supported (one yielded row per affected row). EF Core 8's SaveChanges flow emits `OUTPUT 1` as a rows-affected detector on every modify and remove — that's the case this enables. Storage uses page-slot tombstones (high bit on slot directory entry; row payload bytes not reclaimed) and orphaned LOB chains stay in `Heap.LobPages` (see Quirks below).
169+
- `OUTPUT` clause on UPDATE / DELETE supports `INSERTED.<col>` (post-update / new value), `DELETED.<col>` (pre-update / old value), and literal / parameter expressions. UPDATE allows both qualifiers; DELETE rejects `INSERTED.<col>` at parse time → **Msg 4104** (verbatim probed). Bare column refs → Msg 207. Star expansion (`INSERTED.*` / `DELETED.*`) and table-alias qualifiers aren't modeled (parse error). Storage uses page-slot tombstones (high bit on slot directory entry; row payload bytes not reclaimed) and orphaned LOB chains stay in `Heap.LobPages` (see Quirks below).
170+
171+
### `rowversion` (legacy synonym `timestamp`)
172+
8-byte big-endian auto-generated counter, implicitly NOT NULL, at most one per table. Database-scoped monotonic counter (`Simulation.AllocateRowVersion`) — every INSERT into a rowversion-bearing table and every UPDATE that affects a row in one allocates the next value. Storage type name surfaces as `timestamp` in `information_schema` and SqlClient's `DataTypeName` regardless of which keyword the column was declared with.
173+
174+
- Explicit value in INSERT column list → **Msg 273** `"Cannot insert an explicit value into a timestamp column. ..."`.
175+
- Explicit value in UPDATE SET → **Msg 272** `"Cannot update a timestamp column."`.
176+
- Second rowversion column on a table → **Msg 2738** `"A table can only have one timestamp column. ..."`.
177+
- Outbound CAST: `varbinary(N)` and `binary(N)` copy the 8 bytes; `bigint` reads them big-endian (matches the `@@DBTS`-style integer view real SQL Server exposes). No reverse-direction CAST — rowversion values can only be auto-generated.
178+
- `Promote(RowVersion, Varbinary)``Varbinary` so the EF Core optimistic-concurrency `WHERE [rv] = @originalRv` pattern (where the parameter binds as `varbinary`) works directly.
179+
- `EF Core [Timestamp]` round-trip works end-to-end: SaveChanges of a modified entity emits `UPDATE ... OUTPUT INSERTED.[RowVersion] WHERE [Id] = @p AND [RowVersion] = @originalRv` and the simulator returns the new rowversion through OUTPUT for EF's change tracker.
170180

171181
### MERGE / OUTPUT (EF Core SaveChanges shape only)
172182
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
@@ -196,10 +206,9 @@ Comparison (Msg 402), ORDER BY / DISTINCT (Msg 306), and aggregates (Msg 8117 fr
196206
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121` for date-like → string. Other styles raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date parsing not modeled.
197207
- Cross-category `Promote` for integer ↔ string. Only CAST works that pair.
198208
- `LEN(ntext)` raising Msg 8116 (function-level text/ntext/image restrictions); legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
199-
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, `INSERTED.*` star expansion. Full `OUTPUT INSERTED.<col>` / `OUTPUT DELETED.<col>` support on UPDATE / DELETE (literal-only OUTPUT *is* supported — see "What's modeled / UPDATE / DELETE"). The INSERTED / DELETED resolver work pairs with the rowversion + MERGE WHEN MATCHED bundle that closes the optimistic-concurrency story; trigger support also depends on it.
209+
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion. Per-column `OUTPUT INSERTED.<col>` / `OUTPUT DELETED.<col>` *is* supported (UPDATE / DELETE both); only the star-expansion form is missing. `OUTPUT INTO` (sending the projection to a table variable rather than the result set) isn't.
200210
- Multi-table UPDATE / DELETE (`UPDATE alias SET ... FROM table AS alias`, `DELETE alias FROM ...`). EF7+ `ExecuteUpdate` / `ExecuteDelete` emit these and won't work without the bundle.
201-
- `rowversion` type (paired with full UPDATE / DELETE OUTPUT for `[Timestamp]` concurrency tracking).
202-
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
211+
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`. EF Core uses `WHEN MATCHED` branches for batched updates and the optimistic-concurrency story isn't complete until that lands. Triggers (which depend on the same INSERTED / DELETED projection model already wired here) are a downstream beneficiary.
203212
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
204213
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).
205214
- `PRIMARY KEY` / `UNIQUE` on a computed column (would need to evaluate the expression against every existing row at insert; `NotSupportedException`).

SqlServerSimulator.Tests.EFCore/EFCoreUpdateDelete.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Microsoft.EntityFrameworkCore;
2+
13
namespace SqlServerSimulator;
24

35
/// <summary>
@@ -88,4 +90,51 @@ public async Task SaveChanges_ModifyAndRemove_BothApplied()
8890
var rows = fresh.People.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
8991
CollectionAssert.AreEqual(new[] { "AAA", "carol", "dave" }, rows);
9092
}
93+
94+
[TestMethod]
95+
public async Task SaveChanges_TimestampEntity_ReadsBackAutoBumpedRowVersion()
96+
{
97+
// EF Core's [Timestamp]-tracked entity emits
98+
// UPDATE [T] SET [Name] = @p0 OUTPUT INSERTED.[RowVersion] WHERE [Id] = @p1 AND [RowVersion] = @p2;
99+
// on SaveChanges. The simulator must auto-bump rv on UPDATE, accept
100+
// the varbinary parameter in the WHERE comparison, and surface the
101+
// new value via OUTPUT. After SaveChanges, EF Core compares the
102+
// tracked entity's RowVersion to detect concurrency failures.
103+
var simulation = new Simulation();
104+
_ = simulation.ExecuteNonQuery(
105+
"create table Items (Id int primary key, Name nvarchar(50), RowVersion rowversion)");
106+
107+
using var context = new TimestampedDbContext(simulation);
108+
var item = new TimestampedItem { Id = 1, Name = "first" };
109+
_ = context.Items.Add(item);
110+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
111+
var initialRv = item.RowVersion!.ToArray();
112+
113+
item.Name = "second";
114+
_ = await context.SaveChangesAsync(this.TestContext.CancellationToken);
115+
var bumpedRv = item.RowVersion!.ToArray();
116+
117+
Assert.IsFalse(initialRv.SequenceEqual(bumpedRv), "rowversion must change after SaveChanges of a modified entity");
118+
}
119+
}
120+
121+
internal class TimestampedItem
122+
{
123+
public int Id { get; set; }
124+
public string Name { get; set; } = "";
125+
126+
[System.ComponentModel.DataAnnotations.Timestamp]
127+
public byte[]? RowVersion { get; set; }
128+
}
129+
130+
internal class TimestampedDbContext(Simulation simulation) : DbContext
131+
{
132+
public Simulation Simulation { get; set; } = simulation;
133+
134+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
135+
{
136+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
137+
}
138+
139+
public DbSet<TimestampedItem> Items => Set<TimestampedItem>();
91140
}

SqlServerSimulator.Tests/DeleteTests.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,28 @@ public void Delete_OutputLiteralOne_YieldsOneRowPerAffected()
133133
}
134134

135135
[TestMethod]
136-
public void Delete_OutputDeletedColumn_RaisesNotSupported()
136+
public void Delete_OutputDeletedColumn_YieldsRemovedValue()
137137
{
138+
// DELETED.<col> on DELETE returns the pre-delete value (probe-confirmed).
138139
var simulation = new Simulation();
139140
_ = simulation.ExecuteNonQuery("create table t (id int)");
140141
_ = simulation.ExecuteNonQuery("insert into t values (1)");
141142

142-
_ = Throws<NotSupportedException>(() =>
143-
_ = simulation.ExecuteScalar("delete from t output deleted.id where id = 1"));
143+
var deletedId = simulation.ExecuteScalar("delete from t output deleted.id where id = 1");
144+
AreEqual(1, deletedId);
145+
}
146+
147+
[TestMethod]
148+
public void Delete_OutputInsertedColumn_RaisesMsg4104()
149+
{
150+
// INSERTED isn't a valid qualifier in DELETE OUTPUT (probe-confirmed Msg 4104).
151+
var simulation = new Simulation();
152+
_ = simulation.ExecuteNonQuery("create table t (id int)");
153+
_ = simulation.ExecuteNonQuery("insert into t values (1)");
154+
155+
var ex = Throws<DbException>(() =>
156+
_ = simulation.ExecuteScalar("delete from t output inserted.id where id = 1"));
157+
AreEqual("4104", ex.Data["HelpLink.EvtID"]);
144158
}
145159

146160
[TestMethod]
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
using System.Data.Common;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Behavioral tests for the <c>rowversion</c> / <c>timestamp</c> type:
8+
/// auto-generation on INSERT, auto-bump on UPDATE, rejection of explicit
9+
/// values (Msg 273 / Msg 272), one-per-table (Msg 2738), implicit
10+
/// NOT NULL, comparison with <c>varbinary</c> for optimistic-concurrency
11+
/// WHERE clauses, and <c>CAST</c> outbound to <c>bigint</c> /
12+
/// <c>varbinary</c>. Sourced from probes against SQL Server 2025.
13+
/// </summary>
14+
[TestClass]
15+
public sealed class RowVersionTests
16+
{
17+
private static byte[] ReadRowVersion(DbCommand command, int ordinal = 0)
18+
{
19+
using var reader = command.ExecuteReader();
20+
IsTrue(reader.Read());
21+
return (byte[])reader.GetValue(ordinal);
22+
}
23+
24+
private static List<byte[]> ReadAllRowVersions(DbCommand command)
25+
{
26+
using var reader = command.ExecuteReader();
27+
var values = new List<byte[]>();
28+
while (reader.Read())
29+
values.Add((byte[])reader.GetValue(0));
30+
return values;
31+
}
32+
33+
// === Type declaration ===
34+
35+
[TestMethod]
36+
public void RowVersion_KeywordAccepted()
37+
{
38+
var simulation = new Simulation();
39+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
40+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
41+
42+
var rvs = ReadAllRowVersions(simulation.CreateCommand("select rv from t"));
43+
HasCount(1, rvs);
44+
HasCount(8, rvs[0]);
45+
}
46+
47+
[TestMethod]
48+
public void Timestamp_KeywordAccepted_LegacySynonym()
49+
{
50+
var simulation = new Simulation();
51+
_ = simulation.ExecuteNonQuery("create table t (id int, ts timestamp)");
52+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
53+
54+
var rvs = ReadAllRowVersions(simulation.CreateCommand("select ts from t"));
55+
HasCount(1, rvs);
56+
HasCount(8, rvs[0]);
57+
}
58+
59+
[TestMethod]
60+
public void TwoRowVersionColumns_RaisesMsg2738()
61+
{
62+
var ex = Throws<DbException>(() =>
63+
_ = new Simulation().ExecuteNonQuery("create table t (rv1 rowversion, rv2 rowversion)"));
64+
AreEqual("2738", ex.Data["HelpLink.EvtID"]);
65+
}
66+
67+
// === Auto-generation on INSERT ===
68+
69+
[TestMethod]
70+
public void Insert_AutoGeneratesRowVersionWhenColumnOmitted()
71+
{
72+
var simulation = new Simulation();
73+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
74+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1), (2), (3)");
75+
76+
var rvs = ReadAllRowVersions(simulation.CreateCommand("select rv from t"));
77+
HasCount(3, rvs);
78+
// Each is 8 bytes, monotonically increasing.
79+
var rv0 = BitConverter.ToUInt64(rvs[0].Reverse().ToArray());
80+
var rv1 = BitConverter.ToUInt64(rvs[1].Reverse().ToArray());
81+
var rv2 = BitConverter.ToUInt64(rvs[2].Reverse().ToArray());
82+
IsLessThan(rv1, rv0);
83+
IsLessThan(rv2, rv1);
84+
}
85+
86+
[TestMethod]
87+
public void Insert_RowVersionListedExplicitly_RaisesMsg273()
88+
{
89+
var simulation = new Simulation();
90+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
91+
92+
var ex = Throws<DbException>(() =>
93+
_ = simulation.ExecuteNonQuery("insert into t (id, rv) values (1, 0x00000000000000FF)"));
94+
AreEqual("273", ex.Data["HelpLink.EvtID"]);
95+
}
96+
97+
[TestMethod]
98+
public void Insert_NoColumnList_AutoGeneratesAndDoesntFail()
99+
{
100+
// Insert without column list: the simulator must skip rowversion when
101+
// synthesizing the destination list (rowversion is auto-only).
102+
var simulation = new Simulation();
103+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
104+
_ = simulation.ExecuteNonQuery("insert into t values (1)");
105+
106+
HasCount(1, ReadAllRowVersions(simulation.CreateCommand("select rv from t")));
107+
}
108+
109+
// === Auto-bump on UPDATE ===
110+
111+
[TestMethod]
112+
public void Update_AutoBumpsRowVersionEvenWhenSetIsUnrelated()
113+
{
114+
var simulation = new Simulation();
115+
_ = simulation.ExecuteNonQuery("create table t (id int, name varchar(20), rv rowversion)");
116+
_ = simulation.ExecuteNonQuery("insert into t (id, name) values (1, 'a')");
117+
118+
var initialRv = ReadRowVersion(simulation.CreateCommand("select rv from t"));
119+
_ = simulation.ExecuteNonQuery("update t set name = 'A' where id = 1");
120+
var afterRv = ReadRowVersion(simulation.CreateCommand("select rv from t"));
121+
122+
IsFalse(initialRv.SequenceEqual(afterRv), "rowversion must change on UPDATE");
123+
}
124+
125+
[TestMethod]
126+
public void Update_SetRowVersion_RaisesMsg272()
127+
{
128+
var simulation = new Simulation();
129+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
130+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
131+
132+
var ex = Throws<DbException>(() =>
133+
_ = simulation.ExecuteNonQuery("update t set rv = 0x00 where id = 1"));
134+
AreEqual("272", ex.Data["HelpLink.EvtID"]);
135+
}
136+
137+
// === CAST outbound ===
138+
139+
[TestMethod]
140+
public void Cast_RowVersion_To_Varbinary8()
141+
{
142+
var simulation = new Simulation();
143+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
144+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
145+
146+
using var connection = simulation.CreateOpenConnection();
147+
using var reader = connection.CreateCommand("select cast(rv as varbinary(8)) from t").ExecuteReader();
148+
IsTrue(reader.Read());
149+
var bytes = (byte[])reader.GetValue(0);
150+
HasCount(8, bytes);
151+
}
152+
153+
[TestMethod]
154+
public void Cast_RowVersion_To_BigInt_BigEndian()
155+
{
156+
var simulation = new Simulation();
157+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
158+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
159+
160+
var asBigInt = (long)simulation.ExecuteScalar("select cast(rv as bigint) from t")!;
161+
// The exact value depends on the simulation's counter, but it must
162+
// match the big-endian interpretation of the same bytes.
163+
var rvBytes = ReadRowVersion(simulation.CreateCommand("select rv from t"));
164+
var expected = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(rvBytes);
165+
AreEqual(expected, asBigInt);
166+
}
167+
168+
// === Comparison with varbinary (optimistic-concurrency WHERE) ===
169+
170+
[TestMethod]
171+
public void Where_RowVersion_Equals_VarbinaryParameter()
172+
{
173+
var simulation = new Simulation();
174+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
175+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
176+
177+
// Capture the current rv, then use it as a varbinary parameter in WHERE.
178+
var rvBytes = ReadRowVersion(simulation.CreateCommand("select rv from t"));
179+
180+
using var connection = simulation.CreateOpenConnection();
181+
using var update = connection.CreateCommand("update t set id = 99 output 1 where id = 1 and rv = @originalRv");
182+
var p = update.CreateParameter();
183+
p.ParameterName = "@originalRv";
184+
p.Value = rvBytes;
185+
_ = update.Parameters.Add(p);
186+
187+
using var reader = update.ExecuteReader();
188+
IsTrue(reader.Read(), "rowversion = varbinary parameter should match the row");
189+
}
190+
191+
[TestMethod]
192+
public void Where_RowVersion_Equals_StaleVarbinary_NoMatch()
193+
{
194+
// Concurrency-violation case: a stale rowversion doesn't match.
195+
var simulation = new Simulation();
196+
_ = simulation.ExecuteNonQuery("create table t (id int, rv rowversion)");
197+
_ = simulation.ExecuteNonQuery("insert into t (id) values (1)");
198+
199+
var staleRv = new byte[8]; // all zeros, never a real rowversion
200+
using var connection = simulation.CreateOpenConnection();
201+
using var update = connection.CreateCommand("update t set id = 99 output 1 where rv = @stale");
202+
var p = update.CreateParameter();
203+
p.ParameterName = "@stale";
204+
p.Value = staleRv;
205+
_ = update.Parameters.Add(p);
206+
207+
using var reader = update.ExecuteReader();
208+
IsFalse(reader.Read());
209+
}
210+
}

0 commit comments

Comments
 (0)