Skip to content

Commit 35ce1c5

Browse files
committed
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).
1 parent e4cd012 commit 35ce1c5

24 files changed

Lines changed: 1584 additions & 15 deletions

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Five scopes, one home each. **Add new state to whichever class matches its true
6262

6363
- **`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. `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"`).
6666
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
6767
- **`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.
6868
- **`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:
149149
- **Touching `DECLARE @t TABLE`, table-variable DML routing, `OUTPUT … INTO <target>` (`@t` or regular)**[`docs/claude/table-variables.md`](docs/claude/table-variables.md).
150150
- **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).
151151
- **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).
152153
- **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).
153154

154155
## Not modeled
@@ -175,7 +176,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
175176
- **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`.
176177
- **`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.
177178
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body and stored-procedure-body value form) ship; unconditional jumps don't.
178-
- 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`).
179180
- **`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.
180181
- `hierarchyid`, `geography`, `geometry`.
181182

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// EF Core's trigger-aware emit surface (<c>ToTable(b =&gt;
7+
/// b.HasTrigger("name"))</c>, introduced in EF Core 7). The annotation
8+
/// tells EF a trigger exists on the entity's table so the provider
9+
/// switches from the default <c>OUTPUT INSERTED</c> SaveChanges shape
10+
/// to a trigger-compatible round-trip (in SQL Server triggers can
11+
/// interfere with direct <c>OUTPUT</c> from INSERT/UPDATE/DELETE). The
12+
/// fixture locks down compatibility with that annotation across EF Core
13+
/// upgrades — if a future EF version changes the trigger-safe emit
14+
/// shape to something the simulator doesn't support yet, these tests
15+
/// catch it. Database-defined trigger behavior itself is covered by
16+
/// <c>TriggerTests</c> in the main test project.
17+
/// </summary>
18+
[TestClass]
19+
public sealed class EFCoreTriggers
20+
{
21+
public TestContext TestContext { get; set; } = null!;
22+
23+
private class Product
24+
{
25+
public int Id { get; set; }
26+
public string Name { get; set; } = null!;
27+
public int Price { get; set; }
28+
}
29+
30+
private class TriggerDbContext(Simulation simulation) : DbContext
31+
{
32+
public Simulation Simulation { get; } = simulation;
33+
34+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
35+
{
36+
_ = optionsBuilder.UseSqlServer(this.Simulation.CreateDbConnection());
37+
}
38+
39+
protected override void OnModelCreating(ModelBuilder modelBuilder)
40+
{
41+
_ = modelBuilder.Entity<Product>().ToTable("Products", t => t.HasTrigger("tr_product_audit"));
42+
}
43+
44+
public DbSet<Product> Products => Set<Product>();
45+
}
46+
47+
[TestMethod]
48+
public void HasTrigger_Insert_FiresAndAuditPopulated()
49+
{
50+
var simulation = new Simulation();
51+
_ = simulation.ExecuteNonQuery("""
52+
create table Products (
53+
Id int identity(1,1) primary key,
54+
Name nvarchar(50) not null,
55+
Price int not null
56+
);
57+
create table ProductAudit (
58+
ProductId int not null,
59+
NewName nvarchar(50) not null,
60+
NewPrice int not null
61+
);
62+
create trigger tr_product_audit on Products after insert
63+
as
64+
insert ProductAudit(ProductId, NewName, NewPrice)
65+
select Id, Name, Price from inserted;
66+
""");
67+
using var context = new TriggerDbContext(simulation);
68+
_ = context.Products.Add(new Product { Name = "Widget", Price = 100 });
69+
_ = context.Products.Add(new Product { Name = "Gadget", Price = 200 });
70+
_ = context.SaveChanges();
71+
72+
// The HasTrigger annotation switches EF's emit shape; whatever
73+
// shape it produces must still flow through the simulator's
74+
// trigger dispatch and populate the audit table.
75+
using var auditConn = simulation.CreateOpenConnection();
76+
using var reader = auditConn
77+
.CreateCommand("select ProductId, NewName, NewPrice from ProductAudit order by ProductId")
78+
.ExecuteReader();
79+
var audit = new List<(int Id, string Name, int Price)>();
80+
while (reader.Read())
81+
audit.Add((reader.GetInt32(0), reader.GetString(1), reader.GetInt32(2)));
82+
Assert.HasCount(2, audit);
83+
Assert.AreEqual("Widget", audit[0].Name);
84+
Assert.AreEqual(100, audit[0].Price);
85+
Assert.AreEqual("Gadget", audit[1].Name);
86+
Assert.AreEqual(200, audit[1].Price);
87+
}
88+
89+
[TestMethod]
90+
public void HasTrigger_SaveChanges_RetrievesGeneratedIdentity()
91+
{
92+
// The trigger-compatible SaveChanges shape still has to return
93+
// generated identity values to the EF entity. Verify the
94+
// round-trip — EF reads Id back from the database after the
95+
// trigger fires, and the in-memory entity reflects it.
96+
var simulation = new Simulation();
97+
_ = simulation.ExecuteNonQuery("""
98+
create table Products (
99+
Id int identity(1,1) primary key,
100+
Name nvarchar(50) not null,
101+
Price int not null
102+
);
103+
create trigger tr_product_audit on Products after insert
104+
as
105+
select 1; -- no-op body; verify identity round-trip works through trigger-safe shape
106+
""");
107+
using var context = new TriggerDbContext(simulation);
108+
var p1 = new Product { Name = "First", Price = 10 };
109+
var p2 = new Product { Name = "Second", Price = 20 };
110+
_ = context.Products.Add(p1);
111+
_ = context.Products.Add(p2);
112+
_ = context.SaveChanges();
113+
114+
Assert.IsGreaterThan(0, p1.Id);
115+
Assert.IsGreaterThan(p1.Id, p2.Id);
116+
}
117+
}

0 commit comments

Comments
 (0)