You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
DML AFTER triggers: CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, DISABLE / ENABLE TRIGGER (single + ALL), FOR-synonym, multi-action AFTER INSERT,UPDATE,DELETE, INSERTED / DELETED resolved via new TriggerFrame on BatchContext (1-part dispatch ahead of schema/temp/@t; both pseudo-tables always populated — empty for the logically-absent side per probe), per-fire HeapTable materialization sharing the parent's columns and flagged IsTableVariable to bypass identity advance / undo-log tracking. Storage: Trigger class (Schema/Name/ObjectId/ParentTable/Actions flags/Timing/BodyText/IsDisabled/CreateDate) + Schema.Triggers sharing the object namespace (Msg 2714 on collision). Dispatch in Simulation.InvokeTrigger.cs walks every schema's Triggers matching table+action+enabled+After, fires bodies through DispatchStatementsUntil in a child BatchContext built via the new trigger-body constructor. Scoping: @@rowcount pre-set to firing DML's count entering the body; SCOPE_IDENTITY saved at FireTriggers entry / restored on exit so trigger-body identity writes don't leak to the outer caller (consequence: @@IDENTITY also reverts post-trigger — technically wrong since real @@IDENTITY is session-wide, but rare-pattern trade); direct-same-trigger recursion suppressed via SimulatedDbConnection.FiringTriggerIds (matches RECURSIVE_TRIGGERS OFF default); TRIGGER_NESTLEVEL() scalar reads new TriggerNestLevel counter. DML hooks at INSERT (regular + INSERT…SELECT + OUTPUT), UPDATE (no-FROM + joined-source — FullOld snapshot capture forced when trigger present), DELETE (both paths), MERGE's INSERT branch; INSTEAD OF parses but raises NotSupportedException (deferred — routes DML through body instead of writer). Errors: CannotDropTriggerDoesNotExist (Msg 3701 trigger variant), ObjectDoesNotExistForTrigger (Msg 8197), reused ThereIsAlreadyAnObject (Msg 2714) and InvalidObjectName (Msg 208 for ALTER-missing + DISABLE/ENABLE on wrong table); bare INSERTED / DELETED outside a trigger surfaces Msg 208 through the standard resolver. Catalog: sys.triggers with 12 columns (name / object_id / parent_class=1 / parent_class_desc='OBJECT_OR_COLUMN' / parent_id / type='TR' / type_desc='SQL_TRIGGER' / dates / is_disabled / is_instead_of_trigger / is_not_for_replication=0); sys.objects extended with TR rows (parent_object_id linked to parent table). ContextualKeywords: After, Disable, Enable, Instead. 27 TriggerTests cover every action combination, recursion suppression (v=10→11→trigger→111 single-cascade), THROW-rolls-back-DML, FOR-synonym, ALTER + CREATE OR ALTER upsert, ALL-form disable, catalog-view shape, all error paths, and the MERGE…OUTPUT-INSERTED shape EF emits for batched SaveChanges. 2 EFCoreTriggers tests lock down EF's HasTrigger("name") C# annotation — switches SaveChanges from OUTPUT INSERTED to SET NOCOUNT ON; INSERT; SELECT … WHERE @@rowcount = 1 AND Id = scope_identity(); the audit-populated + identity-round-trip flows both pass thanks to the SCOPE_IDENTITY scoping fix (drove out a real bug where the trigger's non-identity INSERT was clobbering LastIdentity to NULL). CLAUDE.md "Not modeled" updated (triggers shipped; INSTEAD OF / DDL / logon / RECURSIVE_TRIGGERS ON / UPDATE() / COLUMNS_UPDATED() / sp_settriggerorder still deferred); new docs/claude/triggers.md with implementation map, EF HasTrigger reach + SCOPE_IDENTITY scoping rationale, and gap list (incl. multi-statement-body / mid-body-throw / first-statement-side-effect rollback gap).
Copy file name to clipboardExpand all lines: CLAUDE.md
+3-2Lines changed: 3 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -62,7 +62,7 @@ Five scopes, one home each. **Add new state to whichever class matches its true
62
62
63
63
-**`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
64
64
-**`Database`** (internal) = one database hosted by the server instance. `Schemas` (named-schema dict, pre-seeded with `dbo`), `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
-
-**`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict), `TableTypes` (per-schema user-defined-table-type dict — `CREATE TYPE … AS TABLE` populates; separate namespace from the object dicts, see `docs/claude/table-valued-parameters.md`), `Sequences` (per-schema sequence-object dict — `CREATE SEQUENCE` populates, shares the object namespace with tables/views/funcs/procs, see `docs/claude/sequences.md`). Future triggers land here too. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`, `DECLARE @t audit.MyType`, `NEXT VALUE FOR audit.seq`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
65
+
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict), `TableTypes` (per-schema user-defined-table-type dict — `CREATE TYPE … AS TABLE` populates; separate namespace from the object dicts, see `docs/claude/table-valued-parameters.md`), `Sequences` (per-schema sequence-object dict — `CREATE SEQUENCE` populates, shares the object namespace with tables/views/funcs/procs, see `docs/claude/sequences.md`), `Triggers` (per-schema DML-trigger dict — `CREATE TRIGGER` populates; shares the object namespace, see `docs/claude/triggers.md`). Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`, `DECLARE @t audit.MyType`, `NEXT VALUE FOR audit.seq`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
- **`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`, `TableVariables` (per-batch `DECLARE @t TABLE`–backed `HeapTable` dict, shared namespace with scalar `Variables` for the Msg-134 uniqueness check), `CurrentUndoLog` (per-tx/per-statement, regular tables), `CurrentTableVarUndoLog` (per-statement-only, `@t` writes — kept disjoint from the tx-scoped log so `ROLLBACK TRAN` skips `@t` while statement-atomic rollback covers it), `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), `ProcFrame` (non-null when this batch is a stored-procedure body — same gate for value-form `RETURN`, plus a return-code slot the caller reads), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier and `@t` leaves to `TableVariables` (1-part only — `dbo.@t` returns false; the caller raises Msg 102 at parse via `acceptTableVariable` gating); everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveProcedure(MultiPartName)` resolves through the same schema-lookup path but accepts 1-part names (probe-confirmed: `EXEC p1` finds `dbo.p1`). `TryResolveTableType(MultiPartName)` resolves user-defined table types against the named schema's `TableTypes` dict and falls back to `dbo` for 1-part names (DECLARE @t MyType / TVP-parameter-type lookup). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext, bool acceptTableVariable = false)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name); the `acceptTableVariable` opt-in routes `@t` to a 1-part-with-`@`-prefix leaf for DML / FROM-source sites, and rejects `@t` everywhere else (so ALTER TABLE / DROP TABLE / TRUNCATE / CREATE / SELECT INTO surface Msg 102 matching probe). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF and procedure invocation each allocate a child `BatchContext` via the corresponding body constructor: parameters pre-seed `Variables`, the matching frame is set, the body source text (captured at CREATE FUNCTION / CREATE PROCEDURE time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body. UDF bodies discard yielded result sets at the call site (Msg 444 territory); procedure bodies forward them through to the outer caller's iterator.
68
68
-**`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.
@@ -149,6 +149,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149
149
-**Touching `DECLARE @t TABLE`, table-variable DML routing, `OUTPUT … INTO <target>` (`@t` or regular)** → [`docs/claude/table-variables.md`](docs/claude/table-variables.md).
150
150
-**Touching `CREATE TYPE … AS TABLE`, `DECLARE @t MyType`, TVP procedure parameters / `READONLY`, or the ADO.NET `DbParameter.TypeName` extension (DataTable / IDataReader as a TVP value source)** → [`docs/claude/table-valued-parameters.md`](docs/claude/table-valued-parameters.md).
151
151
-**Touching `CREATE SEQUENCE` / `ALTER SEQUENCE` / `DROP SEQUENCE`, `NEXT VALUE FOR`, the per-row dedup mechanism (`BatchContext.CurrentRowStamp` / `SequenceRowCache`), or `sys.sequences`** → [`docs/claude/sequences.md`](docs/claude/sequences.md).
152
+
-**Touching `CREATE TRIGGER` / `ALTER TRIGGER` / `DROP TRIGGER` / `DISABLE`/`ENABLE TRIGGER`, the `INSERTED` / `DELETED` pseudo-table materialization, the `TriggerFrame` / `FiringTriggerIds` recursion guard, `TRIGGER_NESTLEVEL()`, or `sys.triggers`** → [`docs/claude/triggers.md`](docs/claude/triggers.md).
152
153
-**Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules** → [`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
153
154
154
155
## Not modeled
@@ -175,7 +176,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
175
176
-**CREATE SCHEMA's `<schema_element>` greedy form** — real SQL Server consumes trailing CREATE TABLE / VIEW / GRANT as part of the same CREATE SCHEMA statement (and requires CREATE SCHEMA to be the first statement in the batch as a result). The simulator instead dispatches the trailing tokens as their own statements — same end state for the common idiom, but mismatched-grammar trailers (e.g. anything that isn't a recognized statement start) raise `NotSupportedException`.
176
177
-**`CREATE SCHEMA sys` / `INFORMATION_SCHEMA`** — raises Msg 2760 (matching real SQL Server). The schemas themselves exist as catalog-view hosts (`select * from sys.tables` / `select * from INFORMATION_SCHEMA.COLUMNS` work); legacy bare 1-part system-table access (`select * from systypes`) also still works.
- Multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, triggers. Scalar UDFs, inline TVFs, views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), and sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, and `docs/claude/sequences.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
179
+
- Multi-statement table-valued functions (`RETURNS @t TABLE (...) AS BEGIN ... END`), CLR functions, INSTEAD OF triggers (AFTER triggers ship). DDL / logon triggers also unmodeled. Scalar UDFs, inline TVFs, views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end), and DML AFTER triggers (CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, FOR-as-AFTER synonym, multi-action INSERT,UPDATE,DELETE, INSERTED/DELETED pseudo-tables, multiple triggers per table, DISABLE/ENABLE TRIGGER, TRIGGER_NESTLEVEL(), direct-recursion suppression matching RECURSIVE_TRIGGERS OFF, sys.triggers + sys.objects integration, trigger-error rolls back firing DML) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, `docs/claude/sequences.md`, and `docs/claude/triggers.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
179
180
-**`PRINT` message capture** — the statement parses + evaluates the operand (so operand-side errors like Msg 245 surface), but the message is discarded. `DbConnection` has no `InfoMessage` event (that's a `SqlConnection` extension), so adding a public observability surface would mean a new event on `SimulatedDbConnection`. Defer until an application needs it.
0 commit comments