Skip to content

Commit e18e177

Browse files
committed
Context layering: split Simulation's conflated server+database role into Simulation (instance) + Database (HeapTables, CompatibilityLevel, VerboseTruncationWarnings, @@DBTS rowversion counter); split ParserContext's parse+runtime role into pure-parse ParserContext + BatchContext (Variables, CurrentUndoLog, owns the parser context) + StatementContext (UtcNow frame mutated per dispatch iteration). SimulatedDbConnection gains a CurrentDatabase pointer into the new Databases dict (single "simulated" entry today; USE <db> populates more later). Late-bind switches from Simulation.ActiveConnection to Simulation.ActiveBatch (AsyncLocal<BatchContext?>) — CurrentTimeFunction / LastIdentityExpression / RowCountExpression / IdentCurrent capture Simulation at parse time and resolve through the executing batch at runtime, so a column default's getutcdate() parsed under one batch reads the running batch's frame on later INSERTs.
1 parent c3e34a4 commit e18e177

24 files changed

Lines changed: 319 additions & 209 deletions

CLAUDE.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,16 @@ Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`,
5757
### Expression evaluation
5858
`Expression.Run(columnResolver)` (runtime) and `Expression.GetSqlType(...)` (static, for projection schema) must agree on result type — drift breaks union/CASE/coalesce schema. `BooleanExpression.Run` returns `bool?` (three-valued); WHERE/MERGE-ON exclude UNKNOWN, CHECK passes UNKNOWN. Aggregates: subclass `Aggregator` (`Add(SqlValue)` / `Result()`), register in `AggregateExpression`'s dispatch.
5959

60-
### Known architectural debt — context layering
61-
Real SQL Server has five concentric scopes — **server / database / session / batch / statement**and each piece of state has exactly one home. The simulator collapses these to three buckets and several pieces of state squat in the closest neighbor:
60+
### Context layering
61+
Five scopes, one home each. **Add new state to whichever class matches its true scope**when in doubt, the rule of thumb is: who outlives whom?
6262

63-
- **`Simulation`** = server *and* database. Server-scoped (system tables, `NEWSEQUENTIALID` anchor, rowversion counter) and database-scoped (`HeapTables`, `CompatibilityLevel`, `VerboseTruncationWarnings`) share one class.
64-
- **`SimulatedDbConnection`** = session. Cleanly mapped (`LastIdentity`, `LastStatementRowCount`, `IdentityInsertTable`, `TraceFlags`, `CurrentTransaction`) — *plus* `CurrentStatementUtcNow`, which is semantically statement-scoped but lives here because statement scope has no home.
65-
- **`ParserContext`** = parse-time scratch *and* per-batch runtime context. Pure parse state (`Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`) shares the class with batch-lifetime runtime state (`Variables`, `CurrentUndoLog`).
63+
- **`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary, and the `ActiveBatch` `AsyncLocal` pointer published by the dispatch loop. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
64+
- **`Database`** (internal) = one database hosted by the server instance. `HeapTables`, `CompatibilityLevel`, `VerboseTruncationWarnings`, `rowVersionCounter` (per-DB `@@DBTS`). Every `Simulation` ships with one entry named `Simulation.DefaultDatabaseName` (`"simulated"`); a future `USE <db>` adds entries to the dictionary.
65+
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()` (reads its own `TraceFlags` + the current database's compat / scoped-config).
66+
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `CurrentUndoLog`, plus the per-statement frame `CurrentStatement`. Late-bound expressions (`CurrentTimeFunction`, `LastIdentityExpression`, `RowCountExpression`, `IdentCurrent`) capture `Simulation` at parse time and read through `simulation.ActiveBatch` at runtime, so a column default's `getutcdate()` parsed during one batch correctly resolves against the *executing* batch on later INSERTs.
67+
- **`StatementContext`** (internal, in `Parser/`) = the dispatch loop's per-statement frame. Allocated once per batch and overwritten in place at the top of each iteration; holds `UtcNow` (the per-statement-freeze the time scalars read). Stored-proc EXEC / TRY-CATCH frames slot in here when added.
6668

67-
This is a known smell, not an active task. Refactor trigger: the next feature that genuinely needs a boundary the current shape can't express — `USE <db>` / cross-database queries, true session-vs-global trace-flag scope, stored procedures (which introduce a real per-call statement frame), or two or three consecutive "I had to add it to `Simulation` because no better home exists" moments. Until then, splitting layers churns code without changing user-observable behavior. **Don't stack more state into these buckets unthinkingly** — when adding fields, ask which of the five scopes it actually belongs to and note the mismatch if there is one.
69+
**Don't stack misfit state into these buckets unthinkingly**: when adding fields, ask which scope it actually belongs to. If none fits, that's the signal to introduce the missing scope rather than squat on a neighbor. Multi-database is structurally supported but exercised only by the default entry; `USE <db>` is the trigger to populate the dictionary properly.
6870

6971
## Conventions that fail builds
7072

@@ -177,7 +179,7 @@ Unknown keyword → Msg 155 with the calling function's lowercase name embedded.
177179
### Current-time scalars
178180
Result types: `GETDATE`/`GETUTCDATE`/`CURRENT_TIMESTAMP``datetime`; `SYSDATETIME`/`SYSUTCDATETIME``datetime2(7)`; `SYSDATETIMEOFFSET``datetimeoffset(7)`. EF emits these from `DateTime.UtcNow`/`Now`/`DateTimeOffset.UtcNow` and `HasDefaultValueSql("getutcdate()")`.
179181

180-
**Per-statement freeze**: two `SYSDATETIME()` calls in one SELECT return identical values; an UPDATE that stamps every row writes the same value; successive SELECTs in one batch DO advance. Captured once per statement-loop iteration into `Simulation.CurrentStatementUtcNow`.
182+
**Per-statement freeze**: two `SYSDATETIME()` calls in one SELECT return identical values; an UPDATE that stamps every row writes the same value; successive SELECTs in one batch DO advance. Captured once per statement-loop iteration into `BatchContext.CurrentStatement.UtcNow`.
181183

182184
**UTC == Local** (Azure SQL Database default): no local-time conversion; all six functions return the same UTC instant (rounded per type — datetime variants quantize to 1/300s tick). `SYSDATETIMEOFFSET` reports `+00:00`. Apps depending on `GETDATE``GETUTCDATE` differing by zone won't behave like on-prem; matches cloud default.
183185

@@ -283,9 +285,9 @@ Variable references resolve at runtime via a captured `VariableSlot` — require
283285

284286
**`@@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.
285287

286-
**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.
288+
**Per-session state lives on `SimulatedDbConnection`** (see the Context layering section for the full five-scope map): alongside `@@ROWCOUNT`, the connection carries `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `IdentityInsertTable` (active `SET IDENTITY_INSERT` table), `TraceFlags` (`DBCC TRACEON(N)`), `IsVerboseTruncationActive()` (reads its own `TraceFlags` plus the current database's compat / scoped-config), `CurrentDatabase` (per-session pointer into `Simulation.Databases`), and `CurrentTransaction`. Two connections fanned from one `Simulation` are isolated — matches SQL Server's session = ADO.NET connection scoping.
287289

288-
**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.
290+
**Late-binding via `Simulation.ActiveBatch`**: `CurrentTimeFunction`, `LastIdentityExpression`, `RowCountExpression`, and `IdentCurrent` capture the **`Simulation`** at parse time and look up the runtime-active batch via `Simulation.ActiveBatch` (an `AsyncLocal<BatchContext?>` published by the dispatch loop with try/finally save-restore) when `Run` fires. Parse-time-capture-the-connection-or-batch is wrong for any expression embedded in a column default: `HasDefaultValueSql("getutcdate()")` parses once during CREATE TABLE, then every later INSERT runs in a different batch, and the default has to read *that* batch's per-statement freeze (`ActiveBatch.CurrentStatement.UtcNow`), not the long-gone CREATE-time batch's.
289291

290292
**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.
291293

SqlServerSimulator/Database.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Collections.Concurrent;
2+
using SqlServerSimulator.Storage;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// One simulated SQL Server database. A <see cref="Simulation"/> hosts a
8+
/// dictionary of these, keyed by name; each <see cref="SimulatedDbConnection"/>
9+
/// tracks which one is active via <see cref="SimulatedDbConnection.CurrentDatabase"/>.
10+
/// The shape is in place so <c>USE &lt;db&gt;</c> / temp-table / cross-database
11+
/// features can graft on cleanly later — at the moment every
12+
/// <see cref="Simulation"/> ships with exactly one entry named
13+
/// <see cref="Simulation.DefaultDatabaseName"/>.
14+
/// </summary>
15+
internal sealed class Database(string name)
16+
{
17+
/// <summary>Database name (the key in <see cref="Simulation.Databases"/>).</summary>
18+
public readonly string Name = name;
19+
20+
/// <summary>User tables in this database, keyed by name.</summary>
21+
public readonly ConcurrentDictionary<string, HeapTable> HeapTables = new(Collation.Default);
22+
23+
/// <summary>
24+
/// Database compatibility level. Freshly-constructed databases default
25+
/// to the most recent supported level; user code switches via
26+
/// <c>ALTER DATABASE … SET COMPATIBILITY_LEVEL = N</c>.
27+
/// </summary>
28+
public CompatibilityLevel CompatibilityLevel = CompatibilityLevel.Sql170;
29+
30+
/// <summary>
31+
/// Explicit override of the per-database <c>VERBOSE_TRUNCATION_WARNINGS</c>
32+
/// scoped configuration; <c>null</c> means follow the compatibility-level
33+
/// default. Set via
34+
/// <c>ALTER DATABASE SCOPED CONFIGURATION SET VERBOSE_TRUNCATION_WARNINGS = ON|OFF</c>.
35+
/// </summary>
36+
public bool? VerboseTruncationWarnings;
37+
38+
private long rowVersionCounter;
39+
40+
/// <summary>
41+
/// Allocates the next <c>rowversion</c> counter value (also surfaced as
42+
/// <c>@@DBTS</c> in real SQL Server). Database-scoped, monotonic, shared
43+
/// across every <c>rowversion</c> column in every table — INSERT and
44+
/// UPDATE on a rowversion-bearing table both advance it. The counter is
45+
/// the in-memory representation; the 8-byte big-endian wire form
46+
/// materializes on demand via <see cref="SqlValue.AsBytes"/> /
47+
/// <see cref="RowVersionSqlType.Encode"/>, never per-row in the hot
48+
/// path.
49+
/// </summary>
50+
public long AllocateRowVersion() => Interlocked.Increment(ref this.rowVersionCounter);
51+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using SqlServerSimulator.Storage;
2+
using System.Data.Common;
3+
4+
namespace SqlServerSimulator.Parser;
5+
6+
/// <summary>
7+
/// Per-batch runtime state. One <see cref="BatchContext"/> is constructed
8+
/// per command execution by <see cref="Simulation.CreateResultSetsForCommand"/>;
9+
/// it owns the <see cref="ParserContext"/> that walks the command's tokens
10+
/// and the runtime state both parsing and execution mutate (variable slots,
11+
/// undo log). Parsers see the parser context and reach runtime state via
12+
/// <see cref="ParserContext.Batch"/>; the dispatch loop and writeback
13+
/// helpers operate on the batch context directly.
14+
/// </summary>
15+
internal sealed class BatchContext
16+
{
17+
/// <summary>The parser-side cursor / scratch state for this batch.</summary>
18+
public readonly ParserContext Parser;
19+
20+
/// <summary>
21+
/// Heap-mutation undo log scoped to the current top-level statement. Set
22+
/// by <see cref="Simulation.CreateResultSetsForCommand"/>'s mutation
23+
/// dispatch around each INSERT / UPDATE / DELETE / MERGE; the
24+
/// <see cref="Heap.Insert"/> / <see cref="Heap.DeleteAt"/>
25+
/// call sites read it from here and append entries on success. A
26+
/// statement that throws mid-execution (e.g. a multi-row INSERT whose
27+
/// fourth row violates a constraint) walks the log backwards before the
28+
/// exception propagates, restoring the heap to its pre-statement state.
29+
/// Explicit transactions reuse the same log shape, lifetime extended
30+
/// across statements until COMMIT / ROLLBACK.
31+
/// </summary>
32+
public UndoLog? CurrentUndoLog;
33+
34+
/// <summary>
35+
/// Per-statement scratch frame, allocated once per batch and overwritten
36+
/// in place by the dispatch loop at the top of each statement iteration.
37+
/// See <see cref="StatementContext"/> for the fields it carries.
38+
/// </summary>
39+
public readonly StatementContext CurrentStatement = new();
40+
41+
/// <summary>The connection executing this batch.</summary>
42+
public SimulatedDbConnection Connection => this.Parser.Connection;
43+
44+
/// <summary>The database this batch is executing against.</summary>
45+
public Database CurrentDatabase => this.Parser.CurrentDatabase;
46+
47+
/// <summary>
48+
/// Per-batch variable store. Seeded with SqlClient parameters at
49+
/// construction; <c>DECLARE</c> adds entries; <c>SET</c> /
50+
/// <c>SELECT @v = expr</c> mutate them. Parameters and declared variables
51+
/// share a namespace — a <c>DECLARE</c> whose name collides with a
52+
/// parameter raises Msg 134 (probe-confirmed: real SQL Server treats
53+
/// SqlClient parameters as if they were already declared). End-of-batch
54+
/// write-back to <c>InputOutput</c> / <c>Output</c> direction parameters
55+
/// reads from this store.
56+
/// </summary>
57+
public readonly Dictionary<string, VariableSlot> Variables;
58+
59+
public BatchContext(SimulatedDbCommand command)
60+
{
61+
this.Variables = SeedVariables(command);
62+
this.Parser = new ParserContext(command, this);
63+
}
64+
65+
private static Dictionary<string, VariableSlot> SeedVariables(SimulatedDbCommand command)
66+
{
67+
var dict = new Dictionary<string, VariableSlot>(StringComparer.InvariantCultureIgnoreCase);
68+
foreach (DbParameter parameter in command.Parameters)
69+
{
70+
var name = parameter.ParameterName;
71+
if (name.StartsWith('@'))
72+
name = name[1..];
73+
var dbType = SqlType.GetByDbType(parameter.DbType);
74+
var seed = parameter.Value is null or DBNull
75+
? SqlValue.Null(dbType)
76+
: dbType.ConvertParameter(parameter.Value);
77+
// For decimal / numeric parameters, ConvertParameter widens the
78+
// declared type to fit the value's natural scale (e.g. caller sends
79+
// 123.45m without an explicit scale → widens to decimal(28, 2)).
80+
// Track the post-widen type so VariableReference.GetSqlType returns
81+
// the right schema and downstream readers don't truncate.
82+
var declaredType = seed.IsNull ? dbType : seed.Type;
83+
dict[name] = new VariableSlot(declaredType, declaredMaxLength: null, seed, parameter);
84+
}
85+
return dict;
86+
}
87+
88+
/// <summary>
89+
/// Resolves <paramref name="name"/> to a live <see cref="VariableSlot"/>
90+
/// reference. Captured at parse time by <see cref="Expressions.VariableReference"/>
91+
/// so subsequent <c>SET</c> / <c>SELECT @v = expr</c> mutations are
92+
/// observable when the expression evaluates at runtime — the dictionary
93+
/// is append-only within a batch (re-DECLARE raises Msg 134), so a slot
94+
/// reference captured during parse stays valid.
95+
/// </summary>
96+
/// <exception cref="SimulatedSqlException">Must declare the scalar variable \"@{value of <paramref name="name"/>}\".</exception>
97+
public VariableSlot GetVariableSlot(string name) =>
98+
Variables.TryGetValue(name, out var slot)
99+
? slot
100+
: throw SimulatedSqlException.MustDeclareScalarVariable(name);
101+
}

SqlServerSimulator/Parser/Expressions/CurrentTimeFunction.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ 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-
/// the executing connection's <see cref="SimulatedDbConnection.CurrentStatementUtcNow"/>
23+
/// the executing statement's <see cref="StatementContext.UtcNow"/>
2424
/// — captured once per top-level statement — so multiple calls within one
2525
/// statement return identical values (matching SQL Server's per-statement
2626
/// freeze, probe-confirmed 2026-05-09). The simulator collapses local time
@@ -31,13 +31,12 @@ internal enum CurrentTimeKind
3131
/// <remarks>
3232
/// <para>
3333
/// 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:
34+
/// runtime-active batch via <see cref="Simulation.ActiveBatch"/> when
35+
/// <see cref="Run"/> fires. Critical for column-default reuse:
3636
/// <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.
37+
/// time, but every subsequent INSERT runs in a different batch, and the
38+
/// default has to read that runtime batch's per-statement freeze, not the
39+
/// long-gone CREATE-time batch's.
4140
/// </para>
4241
/// <para>
4342
/// <c>CURRENT_TIMESTAMP</c> is unique in being a parens-less identifier in
@@ -78,7 +77,7 @@ public CurrentTimeFunction(ParserContext context, CurrentTimeKind kind)
7877

7978
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
8079
{
81-
var utcNow = this.simulation.ActiveConnection!.CurrentStatementUtcNow;
80+
var utcNow = this.simulation.ActiveBatch!.CurrentStatement.UtcNow;
8281
return this.Kind switch
8382
{
8483
CurrentTimeKind.GetDate or CurrentTimeKind.GetUtcDate or CurrentTimeKind.CurrentTimestamp =>

SqlServerSimulator/Parser/Expressions/IdentCurrent.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public IdentCurrent(ParserContext context)
3232

3333
public override SqlValue Run(Func<MultiPartName, SqlValue> getColumnValue)
3434
{
35-
if (!this.simulation.HeapTables.TryGetValue(this.tableName, out var table))
35+
if (!this.simulation.ActiveBatch!.CurrentDatabase.HeapTables.TryGetValue(this.tableName, out var table))
3636
return SqlValue.Null(ResultType);
3737
var identityOrdinal = table.IdentityOrdinal;
3838
return identityOrdinal < 0

0 commit comments

Comments
 (0)