Skip to content

Commit 854cd93

Browse files
committed
Explicit RuntimeContext threading: replaces Simulation.ActiveBatch (AsyncLocal<BatchContext?>) with a readonly struct RuntimeContext { ResolveColumn, Batch } passed as the single Expression.Run / BooleanExpression.Run parameter (named runtime at every callsite, symmetric with ParserContext context). Late-bound expressions (CurrentTimeFunction, LastIdentityExpression, RowCountExpression, IdentCurrent) read runtime.Batch.CurrentStatement.UtcNow / runtime.Batch.Connection.LastIdentity / runtime.Batch.CurrentDatabase.HeapTables directly at evaluation time instead of capturing Simulation at parse time — closes the parse-once-run-many side channel that the prior AsyncLocal patched. Selection.Execute(BatchContext, Func<MPN, V>? outerResolver = null) and every internal helper (BuildSqlProjection / BuildAggregateProjectionRows / ProjectSqlRows / ProjectStreaming / ProjectBuffered / ProjectWindowedRows / EnumerateJoinedRows / JoinDriver / CoerceBranchRows + set-op variants / RunRecursiveCte / EnumerateOpenJsonRows / ResolveAcrossTuple + DecodeOrCompute / ComputeOrderKeys) thread batch alongside outerResolver and build new RuntimeContext(perRowResolver, batch) at each Run call. MutationOutputProjection / OutputProjection capture BatchContext at construction so per-row ProjectRow has access; EvaluateComputedColumns / EnforceCheckConstraints take batch. CLAUDE.md's Context layering and Expression evaluation sections drop ActiveBatch references and document the explicit shape.
1 parent e18e177 commit 854cd93

86 files changed

Lines changed: 520 additions & 512 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`,
5555
`SimulatedSqlException` constructor is private; each error case is an `internal static` factory in a topical partial (`TypeErrors`, `SchemaErrors`, `ConstraintErrors`, `ResolutionErrors`, `QueryErrors`, `SyntaxErrors`). The number lands in `Data["HelpLink.EvtID"]`. **Grep for an existing factory before adding a new one.**
5656

5757
### Expression evaluation
58-
`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.
58+
`Expression.Run(RuntimeContext runtime)` (runtime) and `Expression.GetSqlType(...)` (static, for projection schema) must agree on result type — drift breaks union/CASE/coalesce schema. `RuntimeContext` bundles `ResolveColumn` (per-row column lookup) and `Batch` (the executing `BatchContext`); expressions that need batch / session / database state read `runtime.Batch.*` directly. `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

6060
### Context layering
6161
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 / 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.
63+
- **`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
6464
- **`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.
6565
- **`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.
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`. Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch` (see "Expression runtime context" below).
6767
- **`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.
6868

6969
**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.
@@ -287,7 +287,9 @@ Variable references resolve at runtime via a captured `VariableSlot` — require
287287

288288
**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.
289289

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.
290+
**Expression runtime context** (`RuntimeContext`): `Expression.Run` takes a single `RuntimeContext runtime` bundling `ResolveColumn` (per-row column lookup) and `Batch` (the executing `BatchContext`). Most expressions only touch `runtime.ResolveColumn`; the few that need batch-, session-, or database-scoped state (`CurrentTimeFunction`, `LastIdentityExpression`, `RowCountExpression`, `IdentCurrent`) read `runtime.Batch.CurrentStatement.UtcNow`, `runtime.Batch.Connection.LastIdentity`, etc. directly. Explicit threading replaced an earlier `Simulation.ActiveBatch` `AsyncLocal` — necessary because column defaults parsed once at CREATE TABLE run on every later INSERT, possibly from a different batch / connection, and parse-time capture froze them on the wrong batch.
291+
292+
`BooleanExpression.Run(RuntimeContext)` mirrors the same shape (returning `bool?` for three-valued logic). `Selection.Execute(BatchContext, Func<MPN, V>? outerResolver)` threads `batch` through the row pipeline so internal helpers can build `new RuntimeContext(perRowResolver, batch)` at each Run call site. The only callsites that don't have a `BatchContext` in scope are EF-relevant SCALAR expressions evaluated at parse time (TOP / OFFSET / FETCH literals, column-default eval for `BuildSynthesizedSqlRow`) — those construct a `new RuntimeContext(throwingResolver, context.Batch)` from the parser context's batch.
291293

292294
**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.
293295

SqlServerSimulator/Parser/BooleanExpression.cs

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -236,8 +236,8 @@ private static BooleanExpression ParseInList(Expression left, ParserContext cont
236236
/// (only <c>true</c> rows pass); CHECK constraints treat it as pass
237237
/// (only an explicit <c>false</c> rejects the row).
238238
/// </summary>
239-
/// <param name="getColumnValue">Provides the value for a column.</param>
240-
public abstract bool? Run(Func<MultiPartName, SqlValue> getColumnValue);
239+
/// <param name="runtime">Runtime context: column resolver plus batch state.</param>
240+
public abstract bool? Run(RuntimeContext runtime);
241241

242242
/// <summary>
243243
/// Diagnostic-only string rendering, surfaced via
@@ -255,12 +255,12 @@ private static BooleanExpression ParseInList(Expression left, ParserContext cont
255255
/// </summary>
256256
private sealed class AndExpression(BooleanExpression left, BooleanExpression right) : BooleanExpression
257257
{
258-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue)
258+
public override bool? Run(RuntimeContext runtime)
259259
{
260-
var l = left.Run(getColumnValue);
260+
var l = left.Run(runtime);
261261
if (l == false)
262262
return false;
263-
var r = right.Run(getColumnValue);
263+
var r = right.Run(runtime);
264264
return r == false
265265
? false
266266
: l == true && r == true ? true : null;
@@ -276,12 +276,12 @@ private sealed class AndExpression(BooleanExpression left, BooleanExpression rig
276276
/// </summary>
277277
private sealed class OrExpression(BooleanExpression left, BooleanExpression right) : BooleanExpression
278278
{
279-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue)
279+
public override bool? Run(RuntimeContext runtime)
280280
{
281-
var l = left.Run(getColumnValue);
281+
var l = left.Run(runtime);
282282
if (l == true)
283283
return true;
284-
var r = right.Run(getColumnValue);
284+
var r = right.Run(runtime);
285285
return r == true
286286
? true
287287
: l == false && r == false ? false : null;
@@ -300,8 +300,8 @@ private sealed class OrExpression(BooleanExpression left, BooleanExpression righ
300300
/// </summary>
301301
private sealed class IsNullExpression(Expression source, bool negated) : BooleanExpression
302302
{
303-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
304-
source.Run(getColumnValue).IsNull ^ negated;
303+
public override bool? Run(RuntimeContext runtime) =>
304+
source.Run(runtime).IsNull ^ negated;
305305

306306
internal override string DebugDisplay() => $"{source.DebugDisplay()} IS {(negated ? "NOT NULL" : "NULL")}";
307307
}
@@ -317,15 +317,15 @@ private sealed class IsNullExpression(Expression source, bool negated) : Boolean
317317
/// </summary>
318318
private sealed class InExpression(Expression source, Expression[] candidates, bool negated) : BooleanExpression
319319
{
320-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue)
320+
public override bool? Run(RuntimeContext runtime)
321321
{
322-
var src = source.Run(getColumnValue);
322+
var src = source.Run(runtime);
323323
if (src.IsNull)
324324
return null;
325325
var sawNull = false;
326326
foreach (var candidate in candidates)
327327
{
328-
var c = candidate.Run(getColumnValue);
328+
var c = candidate.Run(runtime);
329329
if (c.IsNull)
330330
{
331331
sawNull = true;
@@ -354,8 +354,8 @@ internal override string DebugDisplay()
354354
/// </summary>
355355
private sealed class ExistsExpression(Selection inner) : BooleanExpression
356356
{
357-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
358-
inner.Execute(getColumnValue).RowBytes.Any();
357+
public override bool? Run(RuntimeContext runtime) =>
358+
inner.Execute(runtime.Batch, runtime.ResolveColumn).RowBytes.Any();
359359

360360
internal override string DebugDisplay() => "EXISTS (...)";
361361
}
@@ -373,14 +373,14 @@ private sealed class ExistsExpression(Selection inner) : BooleanExpression
373373
/// </summary>
374374
private sealed class InSubqueryExpression(Expression source, Selection inner, bool negated) : BooleanExpression
375375
{
376-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue)
376+
public override bool? Run(RuntimeContext runtime)
377377
{
378-
var src = source.Run(getColumnValue);
378+
var src = source.Run(runtime);
379379
if (src.IsNull)
380380
return null;
381381

382382
var sawNull = false;
383-
var resultSet = inner.Execute(getColumnValue);
383+
var resultSet = inner.Execute(runtime.Batch, runtime.ResolveColumn);
384384
foreach (var rowBytes in resultSet.RowBytes)
385385
{
386386
var rowValue = RowDecoder.DecodeColumn(resultSet.Schema, rowBytes, 0);
@@ -406,7 +406,7 @@ private sealed class InSubqueryExpression(Expression source, Selection inner, bo
406406
/// </summary>
407407
private sealed class NotExpression(BooleanExpression inner) : BooleanExpression
408408
{
409-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) => inner.Run(getColumnValue) switch
409+
public override bool? Run(RuntimeContext runtime) => inner.Run(runtime) switch
410410
{
411411
true => false,
412412
false => true,
@@ -451,8 +451,8 @@ private protected CompareExpression(Expression left, ParserContext context)
451451
/// equality slot, and the operator name is woven into the message via
452452
/// the caller-supplied <paramref name="operatorName"/>.
453453
/// </summary>
454-
protected static bool? ComparePromoted(Expression left, Expression right, Func<MultiPartName, SqlValue> getColumnValue, string operatorName, Func<SqlValue, SqlValue, bool> compare) =>
455-
CompareValuesPromoted(left.Run(getColumnValue), right.Run(getColumnValue), operatorName, compare);
454+
protected static bool? ComparePromoted(Expression left, Expression right, RuntimeContext runtime, string operatorName, Func<SqlValue, SqlValue, bool> compare) =>
455+
CompareValuesPromoted(left.Run(runtime), right.Run(runtime), operatorName, compare);
456456
}
457457

458458
/// <summary>
@@ -478,48 +478,48 @@ private protected CompareExpression(Expression left, ParserContext context)
478478

479479
private sealed class EqualityExpression(Expression left, ParserContext context) : CompareExpression(left, context)
480480
{
481-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
482-
ComparePromoted(left, right, getColumnValue, "equal to", static (l, r) => l.Equals(r));
481+
public override bool? Run(RuntimeContext runtime) =>
482+
ComparePromoted(left, right, runtime, "equal to", static (l, r) => l.Equals(r));
483483

484484
internal override string DebugDisplay() => $"{left.DebugDisplay()} = {right.DebugDisplay()}";
485485
}
486486

487487
private sealed class InequalityExpression(Expression left, ParserContext context) : CompareExpression(left, context)
488488
{
489-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
490-
ComparePromoted(left, right, getColumnValue, "not equal to", static (l, r) => !l.Equals(r));
489+
public override bool? Run(RuntimeContext runtime) =>
490+
ComparePromoted(left, right, runtime, "not equal to", static (l, r) => !l.Equals(r));
491491

492492
internal override string DebugDisplay() => $"{left.DebugDisplay()} <> {right.DebugDisplay()}";
493493
}
494494

495495
private sealed class GreaterThanExpression(Expression left, Expression right) : CompareExpression(left, right)
496496
{
497-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
498-
ComparePromoted(left, right, getColumnValue, "greater than", static (l, r) => l.CompareTo(r) > 0);
497+
public override bool? Run(RuntimeContext runtime) =>
498+
ComparePromoted(left, right, runtime, "greater than", static (l, r) => l.CompareTo(r) > 0);
499499

500500
internal override string DebugDisplay() => $"{left.DebugDisplay()} > {right.DebugDisplay()}";
501501
}
502502

503503
private sealed class GreaterThanOrEqualExpression(Expression left, ParserContext context) : CompareExpression(left, context)
504504
{
505-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
506-
ComparePromoted(left, right, getColumnValue, "greater than or equal to", static (l, r) => l.CompareTo(r) >= 0);
505+
public override bool? Run(RuntimeContext runtime) =>
506+
ComparePromoted(left, right, runtime, "greater than or equal to", static (l, r) => l.CompareTo(r) >= 0);
507507

508508
internal override string DebugDisplay() => $"{left.DebugDisplay()} >= {right.DebugDisplay()}";
509509
}
510510

511511
private sealed class LessThanExpression(Expression left, Expression right) : CompareExpression(left, right)
512512
{
513-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
514-
ComparePromoted(left, right, getColumnValue, "less than", static (l, r) => l.CompareTo(r) < 0);
513+
public override bool? Run(RuntimeContext runtime) =>
514+
ComparePromoted(left, right, runtime, "less than", static (l, r) => l.CompareTo(r) < 0);
515515

516516
internal override string DebugDisplay() => $"{left.DebugDisplay()} < {right.DebugDisplay()}";
517517
}
518518

519519
private sealed class LessThanOrEqualExpression(Expression left, ParserContext context) : CompareExpression(left, context)
520520
{
521-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue) =>
522-
ComparePromoted(left, right, getColumnValue, "less than or equal to", static (l, r) => l.CompareTo(r) <= 0);
521+
public override bool? Run(RuntimeContext runtime) =>
522+
ComparePromoted(left, right, runtime, "less than or equal to", static (l, r) => l.CompareTo(r) <= 0);
523523

524524
internal override string DebugDisplay() => $"{left.DebugDisplay()} <= {right.DebugDisplay()}";
525525
}
@@ -539,10 +539,10 @@ private sealed class LikeExpression(Expression left, Expression right, Expressio
539539
private readonly Expression? escape = escape;
540540
private readonly bool negated = negated;
541541

542-
public override bool? Run(Func<MultiPartName, SqlValue> getColumnValue)
542+
public override bool? Run(RuntimeContext runtime)
543543
{
544-
var l = left.Run(getColumnValue);
545-
var r = right.Run(getColumnValue);
544+
var l = left.Run(runtime);
545+
var r = right.Run(runtime);
546546
if (l.IsNull || r.IsNull)
547547
return null;
548548

@@ -552,7 +552,7 @@ private sealed class LikeExpression(Expression left, Expression right, Expressio
552552
char? escapeChar = null;
553553
if (this.escape is not null)
554554
{
555-
var e = this.escape.Run(getColumnValue);
555+
var e = this.escape.Run(runtime);
556556
if (e.IsNull)
557557
return null;
558558
if (e.Type.Category != SqlTypeCategory.String)

0 commit comments

Comments
 (0)