Skip to content

Commit 9fec6b9

Browse files
committed
Added multi-table-syntax UPDATE / DELETE (UPDATE alias SET ... FROM table AS alias, DELETE FROM alias FROM table AS alias) — the EF7+ ExecuteUpdate / ExecuteDelete emission shape. Single-source-only; joined-source FROM clauses raise NotSupportedException.
1 parent bb9761f commit 9fec6b9

10 files changed

Lines changed: 366 additions & 36 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Priority is opportunistic: each bundle picks the lowest-effort path that unlocks
1818

1919
Standing pattern for non-trivial SQL feature work:
2020

21-
1. **Probe.** Behavior questions get answered against a real SQL Server 2025 instance, not from memory or docs. Connection details for the user's reference instance live in user memory under "Real SQL Server reference instance."
21+
1. **Probe.** Behavior questions get answered against a real SQL Server 2025 instance, not from memory or docs. Connection details for the user's reference instance live in user memory under "Real SQL Server reference instance." Probe scaffolds — both raw SqlClient probes and EF Core emission probes — live in `/tmp/<probe-name>/` console projects and get deleted after the bundle. The git workspace stays free of probe scratch; only graduated regression tests land in `*.Tests` / `*.Tests.EFCore`.
2222
2. **Surface decisions.** Before writing code, surface 2–3 concrete design choices and recommend one each. The user is decisive at choice points.
2323
3. **Implement + test.** Tests in `*.Tests` exercise the public API path; `*.Tests.EFCore` validates the oracle. Use `*.Tests.Internal` only for things genuinely unreachable from public SQL.
2424
4. **Update CLAUDE.md.** Move bullets between "What's modeled" / "Not modeled" / "Quirks" as scope changes.
@@ -188,7 +188,8 @@ Per-type keyword compatibility mirrors SQL Server (verified against Kardax7 2026
188188
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree backing.
189189

190190
### UPDATE / DELETE
191-
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`. Single-table only.
191+
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`.
192+
- Multi-table-syntax form (`UPDATE alias SET alias.col = expr FROM table AS alias [WHERE pred]`, `DELETE FROM alias FROM table AS alias [WHERE pred]`) is the EF7+ `ExecuteUpdate` / `ExecuteDelete` shape. Single-source-only — additional sources or joins on the FROM clause raise `NotSupportedException`. Two-pass parsing: collect raw `(columnName, expr)` pairs without resolving ordinals, then bind to the FROM-clause table once known. SET LHS supports both bare `col = expr` and alias-qualified `[a].[col] = expr`; the alias prefix is accepted verbatim and not cross-checked against the FROM-clause's alias since the simulator's row resolvers use `name.Leaf` (the alias is moot for single-source). OUTPUT is only supported on the single-table form (EF doesn't combine OUTPUT with multi-table-syntax) — see `Simulation.Update.cs` / `Simulation.Delete.cs` for the deferred-table-binding pattern.
192193
- **Multi-column SET evaluates RHS against the pre-update row snapshot** — verified: `UPDATE t SET a = 100, b = a + 1` over `(a=10, b=20)` produces `(a=100, b=11)` (b read pre-update a). Scalar subquery RHS sees the pre-update table state.
193194
- Identity-column update → **Msg 8102** `"Cannot update identity column 'X'."`. Computed-column update → Msg 271 (existing factory). Rowversion update → **Msg 272** `"Cannot update a timestamp column."`.
194195
- Per-row constraint re-validation: NOT NULL → **Msg 515** with `"UPDATE fails."` verb; CHECK → **Msg 547** with `"UPDATE statement"` verb. PK / UNIQUE → Msg 2627 (same wording as INSERT — verbatim SQL Server quirk: "Cannot insert duplicate key" wording even on UPDATE).
@@ -250,7 +251,7 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
250251
- Cross-category `Promote` for integer ↔ string. Only CAST works that pair.
251252
- `LEN(ntext)` raising Msg 8116 (function-level text/ntext/image restrictions); legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
252253
- `OUTPUT INTO @table_var`, `OUTPUT DELETED.*` / `INSERTED.*` star expansion. Per-column `OUTPUT INSERTED.<col>` / `OUTPUT DELETED.<col>` *is* supported (UPDATE / DELETE both); only the star-expansion form is missing. `OUTPUT INTO` (sending the projection to a table variable rather than the result set) isn't.
253-
- Multi-table UPDATE / DELETE (`UPDATE alias SET ... FROM table AS alias`, `DELETE alias FROM ...`). EF7+ `ExecuteUpdate` / `ExecuteDelete` emit these and won't work without the bundle.
254+
- Joined-source UPDATE / DELETE FROM clauses (`UPDATE a SET ... FROM t AS a JOIN u AS b ON ...`). The single-source alias form (`UPDATE a SET ... FROM t AS a [WHERE ...]`, `DELETE FROM a FROM t AS a [WHERE ...]`) IS supported — that's what EF7+ `ExecuteUpdate` / `ExecuteDelete` emit, verified against real SQL Server 2025. Adding sources beyond the single aliased target raises `NotSupportedException` so the gap is visible.
254255
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`. EF Core 9 emits N semicolon-separated `UPDATE … OUTPUT INSERTED.[RowVersion] WHERE [Id] = @p AND [RowVersion] = @p` statements for batched updates (verified 2026-05-07, real SQL Server 2025) — *not* MERGE WHEN MATCHED — so EF SaveChanges fidelity already works without WHEN MATCHED. WHEN MATCHED is still legitimate SQL Server surface (hand-written MERGE, future trigger model) but isn't the EF unlock the older roadmap framed it as. Triggers, when added, will reuse the same INSERTED/DELETED projection model already wired here.
255256
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
256257
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).

SqlServerSimulator.Tests.EFCore/EFCoreAggregates.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,41 @@ public void Aggregate_OnEmptyFilteredSet()
112112
using var context = SeededContext();
113113
Assert.AreEqual(0, context.Filters.Where(f => f.A > 999).Sum(f => f.A));
114114
}
115+
116+
[TestMethod]
117+
public void GroupBy_MultipleAggregatesPerGroup()
118+
{
119+
// GroupBy producing multiple aggregates per key in one projection.
120+
// Verifies the simulator emits all three aggregates from a single
121+
// grouped scan rather than re-issuing per-aggregate.
122+
var simulation = TestDbContext.CreateCustomersSimulation();
123+
using (var seed = new TestDbContext(simulation))
124+
{
125+
seed.CustomerOrders.AddRange(
126+
new CustomerOrder { CustomerId = 1, Amount = 10 },
127+
new CustomerOrder { CustomerId = 1, Amount = 20 },
128+
new CustomerOrder { CustomerId = 2, Amount = 30 },
129+
new CustomerOrder { CustomerId = 2, Amount = 40 });
130+
_ = seed.SaveChanges();
131+
}
132+
133+
using var context = new TestDbContext(simulation);
134+
var summary = context.CustomerOrders
135+
.GroupBy(o => o.CustomerId)
136+
.Select(g => new
137+
{
138+
CustomerId = g.Key,
139+
Total = g.Sum(o => o.Amount),
140+
Count = g.Count(),
141+
Max = g.Max(o => o.Amount),
142+
})
143+
.OrderBy(x => x.CustomerId)
144+
.ToList();
145+
146+
Assert.HasCount(2, summary);
147+
Assert.AreEqual(30m, summary[0].Total);
148+
Assert.AreEqual(2, summary[0].Count);
149+
Assert.AreEqual(20m, summary[0].Max);
150+
Assert.AreEqual(70m, summary[1].Total);
151+
}
115152
}

SqlServerSimulator.Tests.EFCore/EFCoreDecimal.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,17 @@ public void OrderBy_DecimalAscending()
8686
var ordered = context.Products.OrderBy(p => p.Price).Select(p => p.Price).ToArray();
8787
CollectionAssert.AreEqual(new[] { 9.99m, 19.99m, 29.99m }, ordered);
8888
}
89+
90+
[TestMethod]
91+
public void Cast_DecimalToDouble_ProjectsAsFloat()
92+
{
93+
// (double)decimalCol — EF Core emits CAST(... AS float). Common when
94+
// Math functions or aggregations need a wider numeric type.
95+
using var context = new TestDbContext(TestDbContext.CreateProductsSimulation());
96+
_ = context.Products.Add(new Product { Id = 1, Price = 19.99m });
97+
_ = context.SaveChanges();
98+
99+
var asDouble = context.Products.Select(p => (double)p.Price).Single();
100+
Assert.AreEqual(19.99, asDouble, 0.001);
101+
}
89102
}

SqlServerSimulator.Tests.EFCore/EFCoreStrings.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,4 +298,18 @@ public void Insert_MultipleRows_RoundTripsBothColumns()
298298
CollectionAssert.AreEquivalent(new[] { "Alice", "Bob", "Carol" }, names);
299299
CollectionAssert.AreEquivalent(new[] { "A", "B", null }, codes);
300300
}
301+
302+
[TestMethod]
303+
public void StringFunction_IndexOf()
304+
{
305+
// EF Core translates .IndexOf to CHARINDEX-1 (CHARINDEX is 1-based,
306+
// .NET's IndexOf is 0-based; -1 conversion happens server-side).
307+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
308+
_ = context.People.Add(new Person { Id = 1, Name = "hello world" });
309+
_ = context.SaveChanges();
310+
311+
using var fresh = new TestDbContext(context.Simulation);
312+
var index = fresh.People.Select(p => p.Name.IndexOf("world", StringComparison.Ordinal)).Single();
313+
Assert.AreEqual(6, index);
314+
}
301315
}

SqlServerSimulator.Tests.EFCore/EFCoreUpdateDelete.cs

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ namespace SqlServerSimulator;
44

55
/// <summary>
66
/// End-to-end tests for the UPDATE / DELETE shapes EF Core's SqlServer
7-
/// provider emits during <c>SaveChanges</c> on modified or removed
8-
/// entities. Without this bundle the simulator couldn't faithfully stand
9-
/// in for any real EF Core app — the canonical "load, modify, save"
10-
/// flow ended at SaveChanges. Concurrency tokens (rowversion + UPDATE
11-
/// OUTPUT) and the EF7+ bulk operations (ExecuteUpdate / ExecuteDelete)
12-
/// are both deferred to follow-up bundles; see CLAUDE.md.
7+
/// provider emits — both the change-tracker-driven <c>SaveChanges</c>
8+
/// path (single-row UPDATE / DELETE per modified entity, batched as
9+
/// semicolon-separated statements) and the EF7+ bulk
10+
/// <c>ExecuteUpdate</c> / <c>ExecuteDelete</c> path (multi-table-syntax
11+
/// <c>UPDATE [a] SET ... FROM [t] AS [a]</c> / <c>DELETE [a] FROM [t] AS [a]</c>).
12+
/// Optimistic concurrency via <c>[Timestamp]</c> rides through both paths.
1313
/// </summary>
1414
[TestClass]
1515
public class EFCoreUpdateDelete
@@ -163,6 +163,51 @@ public void SaveChanges_ModifyMultipleTimestampedEntities_BatchedUpdatesWithRowV
163163
CollectionAssert.AreEqual(new[] { "a!", "b!", "c!" }, names);
164164
}
165165

166+
[TestMethod]
167+
public void ExecuteUpdate_BulkUpdate_EmitsMultiTableSyntax()
168+
{
169+
// EF7+ ExecuteUpdate emits a multi-table-syntax UPDATE
170+
// UPDATE [a] SET [a].[col] = ... FROM [t] AS [a] WHERE ...
171+
// (probed against real SQL Server 2025, 2026-05-07). The simulator's
172+
// parser accepts the alias-form leading identifier and the trailing
173+
// `FROM <table> AS <alias>` clause; runtime column-resolvers use
174+
// name.Leaf so alias-qualified column refs resolve to the target.
175+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
176+
context.People.AddRange(
177+
new Person { Id = 1, Name = "alice", Code = "A" },
178+
new Person { Id = 2, Name = "bob", Code = "B" },
179+
new Person { Id = 3, Name = "carol", Code = "C" });
180+
_ = context.SaveChanges();
181+
182+
var rows = context.People.Where(p => p.Code == "A" || p.Code == "B")
183+
.ExecuteUpdate(setters => setters.SetProperty(p => p.Name, p => p.Name.ToUpper()));
184+
185+
Assert.AreEqual(2, rows);
186+
using var fresh = new TestDbContext(context.Simulation);
187+
var names = fresh.People.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
188+
CollectionAssert.AreEqual(new[] { "ALICE", "BOB", "carol" }, names);
189+
}
190+
191+
[TestMethod]
192+
public void ExecuteDelete_BulkDelete_EmitsMultiTableSyntax()
193+
{
194+
// EF7+ ExecuteDelete emits
195+
// DELETE FROM [a] FROM [t] AS [a] WHERE ...
196+
using var context = new TestDbContext(TestDbContext.CreatePeopleSimulation());
197+
context.People.AddRange(
198+
new Person { Id = 1, Name = "alice", Code = "A" },
199+
new Person { Id = 2, Name = "bob", Code = "B" },
200+
new Person { Id = 3, Name = "carol", Code = "C" });
201+
_ = context.SaveChanges();
202+
203+
var rows = context.People.Where(p => p.Code == "B").ExecuteDelete();
204+
205+
Assert.AreEqual(1, rows);
206+
using var fresh = new TestDbContext(context.Simulation);
207+
var names = fresh.People.OrderBy(p => p.Id).Select(p => p.Name).ToArray();
208+
CollectionAssert.AreEqual(new[] { "alice", "carol" }, names);
209+
}
210+
166211
[TestMethod]
167212
public void SaveChanges_TimestampEntity_ReadsBackAutoBumpedRowVersion()
168213
{

SqlServerSimulator.Tests/DeleteTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,52 @@ public void Delete_PrimaryKeyTable_AllowsReinsertAfterDelete()
171171
var values = ReadInts(simulation.CreateCommand("select v from t"));
172172
CollectionAssert.AreEqual(new[] { 20 }, values);
173173
}
174+
175+
// === Multi-table-syntax DELETE (EF7+ ExecuteDelete emission) ===
176+
177+
[TestMethod]
178+
public void Delete_MultiTableSyntax_AcceptsAliasFormWithFromClause()
179+
{
180+
var simulation = new Simulation();
181+
_ = simulation.ExecuteNonQuery("create table t (id int)");
182+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3)");
183+
184+
var rows = simulation.ExecuteNonQuery(
185+
"delete from [a] from t as [a] where [a].[id] = 2");
186+
AreEqual(1, rows);
187+
188+
var ids = ReadInts(simulation.CreateCommand("select id from t order by id"));
189+
CollectionAssert.AreEqual(new[] { 1, 3 }, ids);
190+
}
191+
192+
[TestMethod]
193+
public void Delete_MultiTableSyntax_NoWhereClause_DeletesAllRows()
194+
{
195+
var simulation = new Simulation();
196+
_ = simulation.ExecuteNonQuery("create table t (id int)");
197+
_ = simulation.ExecuteNonQuery("insert into t values (1), (2), (3)");
198+
199+
var rows = simulation.ExecuteNonQuery("delete from [a] from t as [a]");
200+
AreEqual(3, rows);
201+
}
202+
203+
[TestMethod]
204+
public void Delete_MultiTableSyntax_AliasUnknownAndNoFromClause_RaisesInvalidObject()
205+
{
206+
var simulation = new Simulation();
207+
_ = simulation.ExecuteNonQuery("create table t (id int)");
208+
209+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("delete from [unknown]"));
210+
Contains("Invalid object name", ex.Message);
211+
}
212+
213+
[TestMethod]
214+
public void Delete_MultiTableSyntax_JoinedFromClause_RaisesNotSupported() =>
215+
ThrowsExactly<NotSupportedException>(() =>
216+
{
217+
var simulation = new Simulation();
218+
_ = simulation.ExecuteNonQuery("create table t (id int)");
219+
_ = simulation.ExecuteNonQuery("create table u (id int)");
220+
_ = simulation.ExecuteNonQuery("delete [a] from t as [a] inner join u as [b] on [a].[id] = [b].[id]");
221+
});
174222
}

SqlServerSimulator.Tests/UpdateTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,4 +390,55 @@ public void Update_ThenSelect_SeesNewState()
390390
names = ReadStrings(simulation.CreateCommand("select name from t"));
391391
CollectionAssert.AreEqual(new[] { "newer" }, names);
392392
}
393+
394+
// === Multi-table-syntax UPDATE (EF7+ ExecuteUpdate emission) ===
395+
396+
[TestMethod]
397+
public void Update_MultiTableSyntax_AcceptsAliasFormWithFromClause()
398+
{
399+
var simulation = new Simulation();
400+
_ = simulation.ExecuteNonQuery("create table t (id int, name varchar(30))");
401+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'a'), (2, 'b'), (3, 'c')");
402+
403+
var rows = simulation.ExecuteNonQuery(
404+
"update [a] set [a].[name] = upper([a].[name]) from t as [a] where [a].[id] = 2");
405+
AreEqual(1, rows);
406+
407+
var names = ReadStrings(simulation.CreateCommand("select name from t order by id"));
408+
CollectionAssert.AreEqual(new[] { "a", "B", "c" }, names);
409+
}
410+
411+
[TestMethod]
412+
public void Update_MultiTableSyntax_NoWhereClause_UpdatesAllRows()
413+
{
414+
var simulation = new Simulation();
415+
_ = simulation.ExecuteNonQuery("create table t (id int, name varchar(30))");
416+
_ = simulation.ExecuteNonQuery("insert into t values (1, 'a'), (2, 'b')");
417+
418+
var rows = simulation.ExecuteNonQuery("update [a] set [a].[name] = 'X' from t as [a]");
419+
AreEqual(2, rows);
420+
421+
var names = ReadStrings(simulation.CreateCommand("select name from t order by id"));
422+
CollectionAssert.AreEqual(new[] { "X", "X" }, names);
423+
}
424+
425+
[TestMethod]
426+
public void Update_MultiTableSyntax_AliasUnknownAndNoFromClause_RaisesInvalidObject()
427+
{
428+
var simulation = new Simulation();
429+
_ = simulation.ExecuteNonQuery("create table t (id int)");
430+
431+
var ex = Throws<DbException>(() => simulation.ExecuteNonQuery("update [unknown] set [unknown].[id] = 1"));
432+
Contains("Invalid object name", ex.Message);
433+
}
434+
435+
[TestMethod]
436+
public void Update_MultiTableSyntax_JoinedFromClause_RaisesNotSupported() =>
437+
ThrowsExactly<NotSupportedException>(() =>
438+
{
439+
var simulation = new Simulation();
440+
_ = simulation.ExecuteNonQuery("create table t (id int)");
441+
_ = simulation.ExecuteNonQuery("create table u (id int)");
442+
_ = simulation.ExecuteNonQuery("update [a] set [a].[id] = 1 from t as [a] inner join u as [b] on [a].[id] = [b].[id]");
443+
});
393444
}

SqlServerSimulator/Parser/ParserContext.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,24 @@ public void MoveNextRequired()
200200
throw SimulatedSqlException.SyntaxErrorNear(previous);
201201
}
202202

203+
/// <summary>
204+
/// Advances <see cref="Token"/> to the next token, throwing an exception
205+
/// if the end was reached or if the new token isn't of type
206+
/// <typeparamref name="T"/>. Use when the caller needs the type assertion
207+
/// but not the token value — pairs with <see cref="GetNextRequired{T}"/>
208+
/// the same way <see cref="MoveNextRequired"/> pairs with
209+
/// <see cref="GetNextRequired"/>.
210+
/// </summary>
211+
/// <typeparam name="T">The expected type of the new token.</typeparam>
212+
/// <exception cref="SimulatedSqlException">Incorrect syntax near '{token}'.</exception>
213+
public void MoveNextRequired<T>()
214+
where T : Token
215+
{
216+
var previous = this.Token;
217+
if (!MoveNext() || this.Token is not T)
218+
throw SimulatedSqlException.SyntaxErrorNear(previous);
219+
}
220+
203221
/// <summary>
204222
/// Updates <see cref="Token"/> with the next usable token in <see cref="commandText"/>.
205223
/// </summary>

0 commit comments

Comments
 (0)