Skip to content

Commit 8d115fd

Browse files
committed
Joined-source UPDATE / DELETE FROM: extends the EF7+ ExecuteUpdate / ExecuteDelete path to support multi-source FROM clauses with INNER/LEFT/CROSS JOIN and CROSS/OUTER APPLY by reusing the SELECT-side join driver. Target source is identified by matching the leading identifier against each FromSource.Qualifier (Msg 208 if unbound); each unique target row is processed exactly once — heap (page, slot) is recovered via a byte[]→address side-channel map populated by a wrapper around the target source's row enumerator. Selection.ParseSourcesAndJoins / FindSourceColumn / EnumerateJoinedRows promoted to internal. OUTPUT in alias-form multi-source raises NotSupportedException (EF Core 10 doesn't combine those).
1 parent 1ce6511 commit 8d115fd

9 files changed

Lines changed: 601 additions & 189 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ Identity counters and the database-scoped rowversion counter bypass the log —
254254

255255
### UPDATE / DELETE
256256
- `UPDATE table SET col = expr [, col = expr]* [WHERE pred]` and `DELETE [FROM] table [WHERE pred]`.
257-
- 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.
257+
- Multi-table-syntax form (`UPDATE alias SET alias.col = expr FROM <sources> [WHERE pred]`, `DELETE FROM alias FROM <sources> [WHERE pred]`) is the EF7+ `ExecuteUpdate` / `ExecuteDelete` shape. Both single-source (`FROM table AS alias`) and joined-source forms (`FROM t AS alias INNER|LEFT|CROSS JOIN u AS b ON ...`, plus `CROSS APPLY` / `OUTER APPLY`) are supported by routing through the SELECT-side `Selection.ParseSourcesAndJoins` + `Selection.EnumerateJoinedRows` machinery. The target source is identified by matching the leading-identifier (alias OR table name) against each source's `FromSource.Qualifier`; missing match → **Msg 208** `"Invalid object name 'X'."` (probe-confirmed against SQL Server 2025; distinct from Msg 4104's multi-part-identifier wording). Two-pass parsing: collect raw `(columnName, expr)` pairs without resolving ordinals, then bind to the target table once known. SET LHS supports both bare `col = expr` and alias-qualified `[a].[col] = expr`; the alias prefix is accepted verbatim. SET RHS can reference any source's columns, qualified or not — resolution goes through `Selection.FindSourceColumn` against the multi-source list.
258+
- **Joined UPDATE / DELETE: each unique target row processed exactly once.** When the same target row matches multiple join tuples (e.g. a customer with two qualifying orders), SQL Server applies the SET / DELETE once per unique target — using the *first* matching tuple's RHS values for SET (heap-scan order, probe-confirmed). The simulator dedupes by `(page, slot)` of the target heap row, recovered via a side-channel byte[]→address map. A wrapper around the target source's `Rows` enumerator records each yielded byte[]'s address into the map as the join driver consumes it; the dedup lookup is reference-equality fast and works whether the target is on the outer or inner side of a join (for inner-side targets, the wrapper repopulates the map on each restart, since the simulator's heap row enumerators allocate fresh byte[] per yield). LEFT JOIN with no right-side match still surfaces the target tuple (probe C); RHS sees NULL for the unmatched-source columns. OUTPUT clause is supported only when the leading identifier resolves to a real table name up-front; OUTPUT alongside an alias-form multi-source UPDATE / DELETE raises `NotSupportedException` (EF Core 10 doesn't combine those, and supporting it would require deferring OUTPUT parsing past the FROM-target binding).
258259
- **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.
259260
- 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."`.
260261
- 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).
@@ -314,7 +315,6 @@ Type-metadata accessors (`GetDataTypeName` / `GetFieldType`) read from `Simulate
314315
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121` for date-like → string. Other styles raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date parsing not modeled.
315316
- `LEN(ntext)` raising Msg 8116 (function-level text/ntext/image restrictions); legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
316317
- `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.
317-
- 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.
318318
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`. EF Core's batched-update path emits semicolon-separated `UPDATE … OUTPUT …` statements rather than `MERGE WHEN MATCHED`, so SaveChanges fidelity doesn't require it.
319319
- Msg 8141 (inline CHECK referencing a peer column — SQL Server rejects at CREATE TABLE; simulator allows).
320320
- Msg 8133 (CASE where every branch is bare `NULL`; simulator returns NULL of `int`).

SqlServerSimulator.Tests/DeleteTests.cs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,70 @@ public void Delete_MultiTableSyntax_AliasUnknownAndNoFromClause_RaisesInvalidObj
211211
}
212212

213213
[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-
});
214+
public void Delete_MultiTableSyntax_JoinedFromClause_DeletesEachTargetOnce()
215+
{
216+
var simulation = new Simulation();
217+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
218+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, total decimal(10, 2))");
219+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'A'), (2, 'B'), (3, 'C')");
220+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 50), (11, 1, 200), (12, 2, 150), (13, 2, 250)");
221+
222+
var rows = simulation.ExecuteNonQuery(
223+
"delete c from customers c inner join orders o on o.customerId = c.id where o.total > 100");
224+
AreEqual(2, rows);
225+
226+
var statuses = new List<string>();
227+
using (var reader = simulation.CreateCommand("select status from customers order by id").ExecuteReader())
228+
while (reader.Read()) statuses.Add(reader.GetString(0));
229+
CollectionAssert.AreEqual(new[] { "C" }, statuses);
230+
}
231+
232+
[TestMethod]
233+
public void Delete_JoinedFromClause_DeleteFromAliasSyntax_AlsoWorks()
234+
{
235+
// DELETE FROM <alias> FROM ... JOIN ... — the form with the extra
236+
// FROM keyword. SQL Server accepts both DELETE alias and DELETE FROM
237+
// alias; EF Core 10's ExecuteDelete emits the DELETE alias form.
238+
var simulation = new Simulation();
239+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
240+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, total decimal(10, 2))");
241+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'A'), (2, 'B')");
242+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 200)");
243+
244+
var rows = simulation.ExecuteNonQuery(
245+
"delete from c from customers c inner join orders o on o.customerId = c.id where o.total > 100");
246+
AreEqual(1, rows);
247+
}
248+
249+
[TestMethod]
250+
public void Delete_JoinedFromClause_AliasNotInFrom_RaisesMsg208()
251+
{
252+
var simulation = new Simulation();
253+
_ = simulation.ExecuteNonQuery("create table t (id int)");
254+
_ = simulation.ExecuteNonQuery("create table u (id int)");
255+
var ex = Throws<DbException>(() =>
256+
_ = simulation.ExecuteNonQuery("delete [x] from t as [a] inner join u as [b] on [a].[id] = [b].[id]"));
257+
Contains("Invalid object name", ex.Message);
258+
}
259+
260+
[TestMethod]
261+
public void Delete_JoinedFromClause_TargetOnRightSide_DeletesFromTarget()
262+
{
263+
// Target alias on the right side of the join (delete orders, not
264+
// customers). Probe O from the design probe.
265+
var simulation = new Simulation();
266+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
267+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, total decimal(10, 2))");
268+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'X')");
269+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 50), (11, 1, 200)");
270+
271+
var rows = simulation.ExecuteNonQuery(
272+
"delete o from customers c inner join orders o on o.customerId = c.id where o.total > 100");
273+
AreEqual(1, rows);
274+
275+
var totals = new List<decimal>();
276+
using var reader = simulation.CreateCommand("select total from orders order by id").ExecuteReader();
277+
while (reader.Read()) totals.Add(reader.GetDecimal(0));
278+
CollectionAssert.AreEqual(new[] { 50m }, totals);
279+
}
222280
}

SqlServerSimulator.Tests/UpdateTests.cs

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -433,12 +433,72 @@ public void Update_MultiTableSyntax_AliasUnknownAndNoFromClause_RaisesInvalidObj
433433
}
434434

435435
[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-
});
436+
public void Update_MultiTableSyntax_JoinedFromClause_UpdatesEachTargetOnce()
437+
{
438+
// Joined-source UPDATE: target rows that match the join exactly once
439+
// are updated; targets that match multiple join rows are still
440+
// updated exactly once (probe-confirmed against SQL Server 2025).
441+
var simulation = new Simulation();
442+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
443+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, total decimal(10, 2))");
444+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'New'), (2, 'New'), (3, 'New')");
445+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 50), (11, 1, 200), (12, 2, 150), (13, 2, 250)");
446+
447+
var rows = simulation.ExecuteNonQuery(
448+
"update c set c.status = 'Active' from customers c inner join orders o on o.customerId = c.id where o.total > 100");
449+
AreEqual(2, rows);
450+
451+
var statuses = ReadStrings(simulation.CreateCommand("select status from customers order by id"));
452+
CollectionAssert.AreEqual(new[] { "Active", "Active", "New" }, statuses);
453+
}
454+
455+
[TestMethod]
456+
public void Update_JoinedFromClause_AliasNotInFrom_RaisesMsg208()
457+
{
458+
var simulation = new Simulation();
459+
_ = simulation.ExecuteNonQuery("create table t (id int)");
460+
_ = simulation.ExecuteNonQuery("create table u (id int)");
461+
var ex = Throws<DbException>(() =>
462+
_ = simulation.ExecuteNonQuery("update [x] set [x].[id] = 1 from t as [a] inner join u as [b] on [a].[id] = [b].[id]"));
463+
Contains("Invalid object name", ex.Message);
464+
}
465+
466+
[TestMethod]
467+
public void Update_JoinedFromClause_LeftJoinNullRight_StillUpdatesTarget()
468+
{
469+
// LEFT JOIN with no right match: target rows still update; SET RHS
470+
// sees NULL for the right-side columns (probe D from the design probe).
471+
var simulation = new Simulation();
472+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
473+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, total decimal(10, 2))");
474+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'New'), (2, 'New')");
475+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 50)");
476+
477+
var rows = simulation.ExecuteNonQuery(
478+
"update c set c.status = 'Touched' from customers c left join orders o on o.customerId = c.id and o.total > 1000");
479+
AreEqual(2, rows);
480+
481+
var statuses = ReadStrings(simulation.CreateCommand("select status from customers order by id"));
482+
CollectionAssert.AreEqual(new[] { "Touched", "Touched" }, statuses);
483+
}
484+
485+
[TestMethod]
486+
public void Update_JoinedFromClause_SetRhsFromOtherSource_UsesFirstMatch()
487+
{
488+
// Multi-match SET RHS: when a target row matches multiple join rows,
489+
// the SET RHS uses the FIRST matching row's value (heap-scan order —
490+
// probe B's nondeterminism, deterministic in heap-scan order).
491+
var simulation = new Simulation();
492+
_ = simulation.ExecuteNonQuery("create table customers (id int primary key, status varchar(20))");
493+
_ = simulation.ExecuteNonQuery("create table orders (id int primary key, customerId int, code varchar(10))");
494+
_ = simulation.ExecuteNonQuery("insert customers values (1, 'New')");
495+
_ = simulation.ExecuteNonQuery("insert orders values (10, 1, 'A'), (11, 1, 'B')");
496+
497+
var rows = simulation.ExecuteNonQuery(
498+
"update c set c.status = o.code from customers c inner join orders o on o.customerId = c.id");
499+
AreEqual(1, rows);
500+
501+
var statuses = ReadStrings(simulation.CreateCommand("select status from customers"));
502+
CollectionAssert.AreEqual(new[] { "A" }, statuses);
503+
}
444504
}

SqlServerSimulator/Parser/FromSource.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ internal sealed class FromSource(
3333
int[]? storageOrdinals,
3434
Heap? lobStore,
3535
IEnumerable<byte[]> rows,
36-
Selection? lateralPlan = null)
36+
Selection? lateralPlan = null,
37+
HeapTable? backingTable = null)
3738
{
3839
public readonly string? Qualifier = qualifier;
3940
public readonly string[] ColumnNames = columnNames;
@@ -43,6 +44,15 @@ internal sealed class FromSource(
4344
public readonly Heap? LobStore = lobStore;
4445
public readonly IEnumerable<byte[]> Rows = rows;
4546

47+
/// <summary>
48+
/// Back-reference to the <see cref="HeapTable"/> when this source is a
49+
/// table (or system table); null for derived-table sources. Used by the
50+
/// UPDATE / DELETE mutation paths to reach the table's
51+
/// <see cref="HeapTable.KeyConstraints"/> / <see cref="HeapTable.Name"/>
52+
/// after FROM parsing has identified which source is the mutation target.
53+
/// </summary>
54+
public readonly HeapTable? BackingTable = backingTable;
55+
4656
/// <summary>
4757
/// When non-null, this source is the right side of a <c>CROSS APPLY</c>
4858
/// or <c>OUTER APPLY</c>. <see cref="Rows"/> is unused; the join driver

SqlServerSimulator/Parser/Selection.Execution.Joins.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ private static SqlValue ResolveAcrossTuple(
4646
/// finish reading each tuple (typically by projecting / encoding the
4747
/// row) before advancing the enumerator.
4848
/// </summary>
49-
private static IEnumerable<byte[]?[]> EnumerateJoinedRows(
49+
internal static IEnumerable<byte[]?[]> EnumerateJoinedRows(
5050
FromSource[] sources,
5151
JoinSpec[] joins,
5252
Func<MultiPartName, SqlValue>? outerResolver)

SqlServerSimulator/Parser/Selection.Execution.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ internal sealed partial class Selection
2626
/// <c>(-1, -1)</c> when no source resolves the name — the caller then
2727
/// falls through to the outer scope.
2828
/// </summary>
29-
private static (int SourceIndex, int ColumnIndex) FindSourceColumn(FromSource[] sources, MultiPartName name)
29+
internal static (int SourceIndex, int ColumnIndex) FindSourceColumn(FromSource[] sources, MultiPartName name)
3030
{
3131
if (name.ImmediateQualifier is { } qualifier)
3232
{

SqlServerSimulator/Parser/Selection.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,29 @@ private static void ParseFromSourceAndJoins(
422422
FromClause fromClause,
423423
Func<MultiPartName, SqlType>? outerTypeResolver,
424424
bool allowOrderBy)
425+
{
426+
ParseSourcesAndJoins(context, depth, sources, joins, outerTypeResolver);
427+
428+
// Now register the multi-source type resolver and parse WHERE / etc.
429+
ConsumeWhereOrderByWithOuterScope(context, fromClause, [.. sources], outerTypeResolver, allowOrderBy);
430+
}
431+
432+
/// <summary>
433+
/// Pure source-and-joins parser, separable from WHERE / ORDER BY
434+
/// consumption. Used by both <see cref="ParseFromSourceAndJoins"/> (which
435+
/// adds WHERE consumption on top) and the UPDATE / DELETE mutation paths
436+
/// (which handle WHERE separately because the leading-identifier target
437+
/// binding has to happen first). Enters with the cursor on the
438+
/// <c>FROM</c> keyword (or, in mutation context, on the FROM keyword
439+
/// position); leaves the cursor at the lookahead-after-last-source token
440+
/// (typically WHERE, end-of-statement, or set-op chain).
441+
/// </summary>
442+
internal static void ParseSourcesAndJoins(
443+
ParserContext context,
444+
uint depth,
445+
List<FromSource> sources,
446+
List<JoinSpec> joins,
447+
Func<MultiPartName, SqlType>? outerTypeResolver)
425448
{
426449
sources.Add(ParseSingleFromSource(context, depth, outerTypeResolver));
427450

@@ -461,9 +484,6 @@ private static void ParseFromSourceAndJoins(
461484
}
462485
joins.Add(new JoinSpec(kind, on));
463486
}
464-
465-
// Now register the multi-source type resolver and parse WHERE / etc.
466-
ConsumeWhereOrderByWithOuterScope(context, fromClause, [.. sources], outerTypeResolver, allowOrderBy);
467487
}
468488

469489
/// <summary>
@@ -546,7 +566,8 @@ private static FromSource ParseSingleFromSource(ParserContext context, uint dept
546566
storedSchema: heapTable.StoredColumns,
547567
storageOrdinals: heapTable.StorageOrdinals,
548568
lobStore: heapTable.Heap,
549-
rows: heapTable.Rows);
569+
rows: heapTable.Rows,
570+
backingTable: heapTable);
550571

551572
case Operator { Character: '(' }:
552573
if (context.GetNextRequired() is not ReservedKeyword { Keyword: Keyword.Select })

0 commit comments

Comments
 (0)