Skip to content

Commit a97a8f3

Browse files
committed
Moved connection-specific state to the connection, fixed tests that relied on incorrect behavior.
1 parent 30771f0 commit a97a8f3

16 files changed

Lines changed: 407 additions & 243 deletions

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ Variable references resolve at runtime via a captured `VariableSlot` — require
272272

273273
**Output-parameter write-back**: at end of batch, the dispatch walks the parameter list and copies each `InputOutput` / `Output` direction parameter's final slot value back to `DbParameter.Value`. Mirrors SqlClient's round-trip behavior for hand-rolled scripts that mutate parameters.
274274

275-
**`@@ROWCOUNT`**: tracks the most-recently-completed statement's row count via `Simulation.LastStatementRowCount`. SELECT row counts populate after the dispatch materializes rows up-front (so the next statement in the batch sees the final count); DML mutations write their affected count; `SET` / `DECLARE @v = init` write 1; bare `DECLARE @v` (no initializer) preserves the prior count; transaction / DDL statements reset to 0.
275+
**`@@ROWCOUNT`**: tracks the most-recently-completed statement's row count via `SimulatedDbConnection.LastStatementRowCount` (per-session state, not shared across connections that fan out from one `Simulation`). SELECT row counts populate after the dispatch materializes rows up-front (so the next statement in the batch sees the final count); DML mutations write their affected count; `SET` / `DECLARE @v = init` write 1; bare `DECLARE @v` (no initializer) preserves the prior count; transaction / DDL statements reset to 0.
276+
277+
**Per-session state lives on `SimulatedDbConnection`**: alongside `@@ROWCOUNT`, the connection carries `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `IdentityInsertTable` (active `SET IDENTITY_INSERT` table), `TraceFlags` (`DBCC TRACEON(N)`), `CurrentStatementUtcNow` (per-statement-freeze for the time scalars), and `IsVerboseTruncationActive()` (which reads its own `TraceFlags` plus the simulation's compatibility level / scoped config). Two connections fanned from one `Simulation` are isolated — matches SQL Server's session = ADO.NET connection scoping.
278+
279+
**Late-binding the executing connection**: `CurrentTimeFunction`, `LastIdentityExpression`, and `RowCountExpression` capture the **`Simulation`** at parse time and look up the runtime-active connection via `Simulation.ActiveConnection` (an `AsyncLocal<SimulatedDbConnection?>` published by the dispatch loop with try/finally save-restore). Parse-time-capture-the-connection is wrong for any expression embedded in a column default: `HasDefaultValueSql("getutcdate()")` parses once during CREATE TABLE on the connection that ran the CREATE, then every later INSERT runs on whichever connection's batch is dispatching, and the default has to read *that* connection's per-statement freeze, not the long-gone CREATE-time connection's.
276280

277281
**Compound assignment** (`SET @v += expr` etc.) and **table variables** (`DECLARE @t TABLE (...)`) aren't modeled — rewrite as `SET @v = @v + expr` for the former; the latter is a separate bundle.
278282

@@ -354,7 +358,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
354358
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).
355359
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
356360
- Heap allocation tracking (flat page list, no IAM/PFS).
357-
- Per-connection session state for `SCOPE_IDENTITY()` / `@@IDENTITY`, `SET IDENTITY_INSERT`'s active table, `DBCC TRACEON(N)` flags — all live on `Simulation` rather than connection. (Tx state is already per-connection.)
358361
- Compound assignment (`SET @v += expr` / `-=` / `*=` etc.) — rewrite as `SET @v = @v + expr`. The arithmetic-operator runtime is locked behind `protected` instance methods on `TwoSidedExpression`; exposing them as static helpers is the prerequisite refactor.
359362
- Table variables (`DECLARE @t TABLE (...)`) — separate feature with its own storage / scope / lifecycle.
360363
- T-SQL control flow (`IF` / `WHILE` / `BEGIN ... END` / `BREAK` / `CONTINUE`) — Bundle 2 of scripting.

SqlServerSimulator.Tests.EFCore/EFCoreIdentity.cs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,16 @@ public void Where_FiltersByAutoGeneratedId()
8888
[TestMethod]
8989
public void SqlQueryRaw_ScopeIdentity_ReturnsLastInsertedValue()
9090
{
91-
// SCOPE_IDENTITY/@@IDENTITY are simulation-wide here (not per-session) —
92-
// see CLAUDE.md's "session-scoped state" gap. That collapse means the
93-
// raw INSERT on a separate connection still updates the value EF reads.
94-
var simulation = TestDbContext.CreateWidgetsSimulation();
95-
_ = simulation
96-
.CreateOpenConnection()
97-
.CreateCommand("insert Widgets (Name) values ('First'),('Second')")
98-
.ExecuteNonQuery();
91+
// SCOPE_IDENTITY/@@IDENTITY are per-session — the priming INSERT and
92+
// the read have to share the EF context's connection.
93+
using var context = new TestDbContext(TestDbContext.CreateWidgetsSimulation());
94+
context.Database.OpenConnection();
95+
using (var cmd = context.Database.GetDbConnection().CreateCommand())
96+
{
97+
cmd.CommandText = "insert Widgets (Name) values ('First'),('Second')";
98+
_ = cmd.ExecuteNonQuery();
99+
}
99100

100-
using var context = new TestDbContext(simulation);
101101
var sid = context.Database.SqlQueryRaw<decimal>("select SCOPE_IDENTITY() as Value").AsEnumerable().Single();
102102
var atid = context.Database.SqlQueryRaw<decimal>("select @@IDENTITY as Value").AsEnumerable().Single();
103103

@@ -128,13 +128,12 @@ public void SqlQueryRaw_ScopeIdentity_BeforeAnyInsertIsNull()
128128
[TestMethod]
129129
public void SaveChanges_WithIdentityInsertOn_PersistsExplicitId()
130130
{
131-
var simulation = TestDbContext.CreateStickersSimulation();
132-
_ = simulation
133-
.CreateOpenConnection()
134-
.CreateCommand("set identity_insert Stickers on")
135-
.ExecuteNonQuery();
131+
// SET IDENTITY_INSERT scopes to the connection — issue it on the EF
132+
// context's connection so SaveChanges runs under the same flag.
133+
using var context = new TestDbContext(TestDbContext.CreateStickersSimulation());
134+
context.Database.OpenConnection();
135+
_ = context.Database.ExecuteSqlRaw("set identity_insert Stickers on");
136136

137-
using var context = new TestDbContext(simulation);
138137
_ = context.Stickers.Add(new Sticker { Id = 100, Tag = "explicit" });
139138
_ = context.SaveChanges();
140139

@@ -145,15 +144,14 @@ public void SaveChanges_WithIdentityInsertOn_PersistsExplicitId()
145144
public void SaveChanges_WithIdentityInsertOn_AdvancesSeed()
146145
{
147146
// Inserting an explicit Id past the high-water mark via EF should
148-
// reseed the same way raw SQL does.
147+
// reseed the same way raw SQL does. SET IDENTITY_INSERT and the
148+
// SaveChanges insert have to share the connection; IDENT_CURRENT
149+
// (database-scoped, not session-scoped) is observable from anywhere.
149150
var simulation = TestDbContext.CreateStickersSimulation();
150-
_ = simulation
151-
.CreateOpenConnection()
152-
.CreateCommand("set identity_insert Stickers on")
153-
.ExecuteNonQuery();
154-
155151
using (var context = new TestDbContext(simulation))
156152
{
153+
context.Database.OpenConnection();
154+
_ = context.Database.ExecuteSqlRaw("set identity_insert Stickers on");
157155
_ = context.Stickers.Add(new Sticker { Id = 50, Tag = "jump" });
158156
_ = context.SaveChanges();
159157
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Per-session state on <see cref="SimulatedDbConnection"/> must not leak
7+
/// between connections that share a <see cref="Simulation"/>. Before
8+
/// per-connection scoping these all lived on <see cref="Simulation"/> and
9+
/// each test below would have failed in the cross-connection direction.
10+
/// </summary>
11+
[TestClass]
12+
public sealed class ConnectionIsolationTests
13+
{
14+
[TestMethod]
15+
public void ScopeIdentity_DoesNotLeakBetweenConnections()
16+
{
17+
var simulation = new Simulation();
18+
_ = simulation.ExecuteNonQuery("create table t (id int identity(1,1), x int)");
19+
20+
using var connA = simulation.CreateOpenConnection();
21+
using var connB = simulation.CreateOpenConnection();
22+
23+
_ = connA.CreateCommand("insert t (x) values (42)").ExecuteNonQuery();
24+
25+
AreEqual(1m, connA.CreateCommand("select SCOPE_IDENTITY()").ExecuteScalar());
26+
AreEqual(DBNull.Value, connB.CreateCommand("select SCOPE_IDENTITY()").ExecuteScalar());
27+
}
28+
29+
[TestMethod]
30+
public void RowCount_DoesNotLeakBetweenConnections()
31+
{
32+
var simulation = new Simulation();
33+
_ = simulation.ExecuteNonQuery("create table t (id int)");
34+
35+
using var connA = simulation.CreateOpenConnection();
36+
using var connB = simulation.CreateOpenConnection();
37+
38+
_ = connA.CreateCommand("insert t values (1),(2),(3)").ExecuteNonQuery();
39+
40+
// Connection B never ran a row-producing statement, so its @@ROWCOUNT
41+
// is still 0 — A's INSERT writing 3 doesn't bleed across.
42+
AreEqual(0, connB.CreateCommand("select @@ROWCOUNT").ExecuteScalar());
43+
}
44+
45+
[TestMethod]
46+
public void IdentityInsert_DoesNotLeakBetweenConnections()
47+
{
48+
var simulation = new Simulation();
49+
_ = simulation.ExecuteNonQuery("create table t (id int identity(1,1), x int)");
50+
51+
using var connA = simulation.CreateOpenConnection();
52+
using var connB = simulation.CreateOpenConnection();
53+
54+
_ = connA.CreateCommand("set identity_insert t on").ExecuteNonQuery();
55+
56+
// SET IDENTITY_INSERT scopes to A. B still sees the column as a
57+
// generated identity and rejects an explicit value with Msg 544.
58+
var ex = Throws<System.Data.Common.DbException>(
59+
() => connB.CreateCommand("insert t (id, x) values (99, 1)").ExecuteNonQuery());
60+
AreEqual("544", ex.Data["HelpLink.EvtID"]);
61+
}
62+
63+
[TestMethod]
64+
public void TimeDefault_ParsedOnOneConnection_RunsOnDispatchingConnection()
65+
{
66+
// Regression coverage for the parse-once-run-many trap: a column
67+
// default's getutcdate() is parsed once at CREATE TABLE time on whichever
68+
// connection issued the CREATE, but every later INSERT can run on a
69+
// different connection. The default must read the *dispatching*
70+
// connection's per-statement freeze, not the long-frozen value left on
71+
// the connection that parsed it. Capturing the connection at parse time
72+
// would freeze both INSERTs at the CREATE-TABLE timestamp.
73+
var simulation = new Simulation();
74+
using var connA = simulation.CreateOpenConnection();
75+
using var connB = simulation.CreateOpenConnection();
76+
77+
_ = connA.CreateCommand(
78+
"create table t (id int identity, note nvarchar(10), stamp datetime2(7) default getutcdate())")
79+
.ExecuteNonQuery();
80+
81+
_ = connB.CreateCommand("insert t (note) values ('a')").ExecuteNonQuery();
82+
Thread.Sleep(10);
83+
_ = connB.CreateCommand("insert t (note) values ('b')").ExecuteNonQuery();
84+
85+
using var reader = connB.CreateCommand("select stamp from t order by id").ExecuteReader();
86+
IsTrue(reader.Read());
87+
var first = reader.GetDateTime(0);
88+
IsTrue(reader.Read());
89+
var second = reader.GetDateTime(0);
90+
IsGreaterThan(first, second);
91+
}
92+
}

SqlServerSimulator.Tests/IdentityTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ select SCOPE_IDENTITY()
5555
[TestMethod]
5656
public void ScopeIdentity_AfterMultiRowInsert_ReturnsLastValue()
5757
{
58-
var simulation = new Simulation();
59-
_ = simulation.ExecuteNonQuery("""
58+
// SCOPE_IDENTITY/@@IDENTITY are per-session (per-connection) so the
59+
// setup INSERT and the read have to share a connection.
60+
using var connection = new Simulation().CreateOpenConnection();
61+
_ = connection.CreateCommand("""
6062
create table t (id int identity(1,1), x int);
6163
insert t (x) values (1),(2),(3)
62-
""");
63-
AreEqual(3m, simulation.ExecuteScalar("select SCOPE_IDENTITY()"));
64-
AreEqual(3m, simulation.ExecuteScalar("select @@IDENTITY"));
64+
""").ExecuteNonQuery();
65+
AreEqual(3m, connection.CreateCommand("select SCOPE_IDENTITY()").ExecuteScalar());
66+
AreEqual(3m, connection.CreateCommand("select @@IDENTITY").ExecuteScalar());
6567
}
6668

6769
// Verified: insert a table without identity clears SCOPE_IDENTITY/@@IDENTITY back to NULL.

SqlServerSimulator/Errors/SimulatedSqlException.ConstraintErrors.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ partial class SimulatedSqlException
1414
/// became the default in SQL Server 2022+ (compatibility level 160+),
1515
/// superseding the legacy <see cref="StringOrBinaryWouldBeTruncatedLegacy"/>
1616
/// (Msg 8152). The simulator selects between the two via
17-
/// <see cref="Simulation.IsVerboseTruncationActive"/>.
17+
/// <see cref="SimulatedDbConnection.IsVerboseTruncationActive"/>.
1818
/// </remarks>
1919
internal static SimulatedSqlException StringOrBinaryWouldBeTruncated(string tableName, string columnName, string value, int max)
2020
{

SqlServerSimulator/Parser/Expressions/CurrentTimeFunction.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,26 @@ internal enum CurrentTimeKind
2020
/// Backs the six current-time scalar functions (<c>GETDATE</c>,
2121
/// <c>GETUTCDATE</c>, <c>SYSDATETIME</c>, <c>SYSUTCDATETIME</c>,
2222
/// <c>SYSDATETIMEOFFSET</c>, <c>CURRENT_TIMESTAMP</c>). All six read from
23-
/// <see cref="Simulation.CurrentStatementUtcNow"/> — captured once per
24-
/// top-level statement — so multiple calls within one statement return
25-
/// identical values (matching SQL Server's per-statement freeze, probe-
26-
/// confirmed 2026-05-09). The simulator collapses local time onto UTC the
27-
/// way Azure SQL Database does by default: every variant returns the same
28-
/// instant; the offset-returning variant reports <c>+00:00</c>.
23+
/// the executing connection's <see cref="SimulatedDbConnection.CurrentStatementUtcNow"/>
24+
/// — captured once per top-level statement — so multiple calls within one
25+
/// statement return identical values (matching SQL Server's per-statement
26+
/// freeze, probe-confirmed 2026-05-09). The simulator collapses local time
27+
/// onto UTC the way Azure SQL Database does by default: every variant
28+
/// returns the same instant; the offset-returning variant reports
29+
/// <c>+00:00</c>.
2930
/// </summary>
3031
/// <remarks>
3132
/// <para>
33+
/// Captures the <see cref="Simulation"/> at parse time and looks up the
34+
/// runtime-active connection via <see cref="Simulation.ActiveConnection"/>
35+
/// when <see cref="Run"/> fires. Critical for column-default reuse:
36+
/// <c>HasDefaultValueSql("getutcdate()")</c> parses once at CREATE TABLE
37+
/// time on the connection that ran the CREATE, but every subsequent
38+
/// INSERT runs on whichever connection's batch is dispatching, and the
39+
/// default has to read that runtime connection's per-statement freeze, not
40+
/// the long-gone CREATE-time connection's.
41+
/// </para>
42+
/// <para>
3243
/// <c>CURRENT_TIMESTAMP</c> is unique in being a parens-less identifier in
3344
/// SQL Server's grammar. The parser recognizes it as
3445
/// <c>ReservedKeyword { Keyword: Keyword.Current_Timestamp }</c> directly in
@@ -67,7 +78,7 @@ public CurrentTimeFunction(ParserContext context, CurrentTimeKind kind)
6778

6879
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
6980
{
70-
var utcNow = this.simulation.CurrentStatementUtcNow;
81+
var utcNow = this.simulation.ActiveConnection!.CurrentStatementUtcNow;
7182
return this.Kind switch
7283
{
7384
CurrentTimeKind.GetDate or CurrentTimeKind.GetUtcDate or CurrentTimeKind.CurrentTimestamp =>

SqlServerSimulator/Parser/Expressions/LastIdentityExpression.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ namespace SqlServerSimulator.Parser.Expressions;
44

55
/// <summary>
66
/// Backs both <c>SCOPE_IDENTITY()</c> and <c>@@IDENTITY</c>: returns the
7-
/// simulation's last-inserted identity value as <c>numeric(38, 0)</c>, or
8-
/// NULL when the most recent INSERT didn't touch an identity column. The
9-
/// two T-SQL surfaces differ in scope on real SQL Server (SCOPE_IDENTITY
10-
/// is per scope, @@IDENTITY is per session); the simulator collapses both
11-
/// to <see cref="Simulation.LastIdentity"/> until session-scoped state is
12-
/// modeled — see the same simplification on <see cref="Simulation.TraceFlags"/>.
7+
/// executing connection's last-inserted identity value as
8+
/// <c>numeric(38, 0)</c>, or NULL when the most recent INSERT didn't touch
9+
/// an identity column. The two T-SQL surfaces differ in scope on real SQL
10+
/// Server (SCOPE_IDENTITY is per scope, @@IDENTITY is per session); the
11+
/// simulator collapses both to <see cref="SimulatedDbConnection.LastIdentity"/>.
1312
/// </summary>
13+
/// <remarks>
14+
/// Captures the <see cref="Simulation"/> at parse time and looks up the
15+
/// runtime-active connection via <see cref="Simulation.ActiveConnection"/>
16+
/// when <see cref="Run"/> fires. Late-binding matters when this expression
17+
/// is parsed once and reused across many statements (e.g. baked into a
18+
/// column default) on possibly-different connections.
19+
/// </remarks>
1420
internal sealed class LastIdentityExpression : Expression
1521
{
1622
private static readonly SqlType ResultType = SqlType.GetDecimal(38, 0);
@@ -27,7 +33,7 @@ public LastIdentityExpression(ParserContext context)
2733
}
2834

2935
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue) =>
30-
this.simulation.LastIdentity is decimal v
36+
this.simulation.ActiveConnection!.LastIdentity is decimal v
3137
? SqlValue.FromDecimal(ResultType, v)
3238
: SqlValue.Null(ResultType);
3339

SqlServerSimulator/Parser/Expressions/RowCountExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ namespace SqlServerSimulator.Parser.Expressions;
1919
internal sealed class RowCountExpression(Simulation simulation) : Expression
2020
{
2121
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue) =>
22-
SqlValue.FromInt32(simulation.LastStatementRowCount);
22+
SqlValue.FromInt32(simulation.ActiveConnection!.LastStatementRowCount);
2323

2424
public override SqlType GetSqlType(Func<MultiPartName, SqlType> resolveColumnType) => SqlType.Int32;
2525

0 commit comments

Comments
 (0)