Skip to content

Commit 72935a1

Browse files
committed
Seek-accelerate MERGE's match phase now supports WHEN NOT MATCHED BY SOURCE.
1 parent 86703ce commit 72935a1

4 files changed

Lines changed: 115 additions & 39 deletions

File tree

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1752,22 +1752,43 @@ merge tgt as t
17521752
}
17531753

17541754
[TestMethod]
1755-
public void MergeNotMatchedBySource_FullScans()
1755+
public void MergeNotMatchedBySource_SeeksThenComplementScans()
17561756
{
17571757
var c = FreshTarget();
1758-
// A NOT MATCHED BY SOURCE clause has to visit every target row, so the
1759-
// inversion declines and the target × source scan stands — no CacheBuild.
1758+
// A NOT MATCHED BY SOURCE clause must visit every target to find the
1759+
// unmatched ones, but the match phase still seeks (CacheBuild): the seek
1760+
// builds matchedByTarget, then one heap pass applies MATCHED to the hits
1761+
// and BY-SOURCE DELETE to the rest — no per-target source loop.
17601762
var trace = ExecTraced(c, """
17611763
merge tgt as t
17621764
using (values (2, 200)) as s(id, v) on t.id = s.id
17631765
when matched then update set v = s.v
17641766
when not matched by source then delete;
17651767
""");
1766-
DoesNotContain("CacheBuild", trace);
1768+
Contains("CacheBuild", trace);
17671769
AreEqual("2", Seq(ReadRows(c, "select id from tgt order by id"))); // 1 and 3 deleted
17681770
AreEqual(200, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2")));
17691771
}
17701772

1773+
[TestMethod]
1774+
public void MergeNotMatchedBySource_ThreeWay_AllBranchesCorrect()
1775+
{
1776+
var c = FreshTarget(); // tgt = (1,10), (2,20), (3,30)
1777+
// id=2 matched → update; id=4 not matched by target → insert; id=1,3 not
1778+
// matched by source → delete. Exercises all three branches through the
1779+
// seek + complement-scan path.
1780+
Exec(c, """
1781+
merge tgt as t
1782+
using (values (2, 222), (4, 444)) as s(id, v) on t.id = s.id
1783+
when matched then update set v = s.v
1784+
when not matched then insert (id, v) values (s.id, s.v)
1785+
when not matched by source then delete;
1786+
""");
1787+
AreEqual("2,4", Seq(ReadRows(c, "select id from tgt order by id")));
1788+
AreEqual(222, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2")));
1789+
AreEqual(444, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 4")));
1790+
}
1791+
17711792
[TestMethod]
17721793
public void MergeSeek_ResidualOnTerm_Filters()
17731794
{

SqlServerSimulator/Simulation/Simulation.Merge.cs

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,56 +1015,77 @@ SqlValue ResolveCombined(SqlValue[]? targetValues, SqlValue[]? sourceValues, Mul
10151015
// Phase A finds, per matched target row, the source rows it matches, then
10161016
// applies the WHEN MATCHED / WHEN NOT MATCHED BY SOURCE action. The
10171017
// target × source scan is O(target × source). When the ON carries a
1018-
// seekable target equality, no NOT MATCHED BY SOURCE clause forces every
1019-
// target to be visited, and the target isn't a view (whose column names
1018+
// seekable target equality and the target isn't a view (whose column names
10201019
// don't map to the base heap), the inverted path seeks matching targets
1021-
// per source row instead and touches only matched targets.
1022-
var hasNotMatchedBySource = whenClauses.Any(c => c.Kind == WhenClauseKind.NotMatchedBySource);
1023-
var targetSeek = sourceView is null && !hasNotMatchedBySource
1020+
// per source row instead — turning the match phase into O(source × log
1021+
// target). With no NOT MATCHED BY SOURCE clause it then touches only the
1022+
// matched targets; with one (which must visit every target to find the
1023+
// unmatched ones) it walks the heap once applying the precomputed matches,
1024+
// dropping the inner source loop either way.
1025+
var targetSeek = sourceView is null
10241026
? Selection.TryPrepareMergeTargetSeek(destinationTable, targetAlias, onPredicate, context.Batch)
10251027
: null;
10261028

10271029
if (targetSeek is not null)
10281030
{
1029-
// Inverted Phase A: per source row, seek the matching target rows and
1030-
// group them by target address — first-source-wins via source-index
1031-
// order (sources iterate ascending), heap order restored by the final
1032-
// (page, slot) sort so the apply order matches the scan path's.
1033-
var matchedByTarget = new Dictionary<(int Page, int Slot), (List<int> Sources, byte[] Bytes)>();
1031+
var hasNotMatchedBySource = whenClauses.Any(c => c.Kind == WhenClauseKind.NotMatchedBySource);
1032+
1033+
// Match phase: per source row, seek the matching target rows and group
1034+
// them by target address — first-source-wins via source-index order
1035+
// (sources iterate ascending). The seek matched the equality prefix
1036+
// only, so the full ON is re-run per candidate (residual filter — a
1037+
// term like … AND t.active = 1, or a stale cache entry, is dropped).
1038+
var matchedByTarget = new Dictionary<(int Page, int Slot), List<int>>();
10341039
for (var si = 0; si < sourceRows.Count; si++)
10351040
{
10361041
var sourceValues = sourceRows[si];
10371042
foreach (var (page, slot, rowBytes) in targetSeek(name => ResolveCombined(null, sourceValues, name)))
10381043
{
1039-
var targetValues = DecodeFullRow(destinationTable, rowBytes);
1040-
EvaluateComputedColumns(destinationTable, targetValues, context.Batch);
1041-
1042-
// The seek matched the equality prefix only; re-run the full ON
1043-
// predicate so a residual term (… AND t.active = 1) or a stale
1044-
// cache entry can't produce a false match.
1045-
if (onPredicate.Run(new RuntimeContext(name => ResolveCombined(targetValues, sourceValues, name), context.Batch)) != true)
1044+
var candidateValues = DecodeFullRow(destinationTable, rowBytes);
1045+
EvaluateComputedColumns(destinationTable, candidateValues, context.Batch);
1046+
if (onPredicate.Run(new RuntimeContext(name => ResolveCombined(candidateValues, sourceValues, name), context.Batch)) != true)
10461047
continue;
10471048

10481049
sourceMatched[si] = true;
1049-
if (!matchedByTarget.TryGetValue((page, slot), out var entry))
1050-
matchedByTarget[(page, slot)] = entry = ([], rowBytes);
1051-
entry.Sources.Add(si);
1050+
if (!matchedByTarget.TryGetValue((page, slot), out var sources))
1051+
matchedByTarget[(page, slot)] = sources = [];
1052+
sources.Add(si);
10521053
}
10531054
}
10541055

1055-
foreach (var address in matchedByTarget.Keys.OrderBy(a => a.Page).ThenBy(a => a.Slot))
1056+
if (hasNotMatchedBySource)
10561057
{
1057-
var (matchedSources, rowBytes) = matchedByTarget[address];
1058-
var targetValues = DecodeFullRow(destinationTable, rowBytes);
1059-
EvaluateComputedColumns(destinationTable, targetValues, context.Batch);
1060-
var sourceValues = sourceRows[matchedSources[0]];
1061-
var chosen = PickClause(whenClauses, WhenClauseKind.Matched, targetValues, sourceValues, context.Batch, ResolveCombined);
1062-
if (chosen is null)
1063-
continue;
1064-
if (chosen.Action == MergeActionKind.Update && matchedSources.Count > 1)
1065-
throw SimulatedSqlException.MergeMultiMatch();
1066-
1067-
ApplyChosenMatchedAction(context, destinationTable, sourceView, chosen, address.Page, address.Slot, targetValues, sourceValues, ResolveCombined, pendingUpdates, pendingDeletes);
1058+
// Complement pass: every target row in heap order — matched rows take
1059+
// their precomputed source list, unmatched rows fall to WHEN NOT
1060+
// MATCHED BY SOURCE. Heap-order interleaving matches the scan path's
1061+
// discovery order, but with no per-target source loop.
1062+
foreach (var (pageIndex, slotIndex, rowBytes) in destinationTable.Heap.EnumerateRowsWithAddress())
1063+
{
1064+
var targetValues = DecodeFullRow(destinationTable, rowBytes);
1065+
EvaluateComputedColumns(destinationTable, targetValues, context.Batch);
1066+
if (matchedByTarget.TryGetValue((pageIndex, slotIndex), out var matchedSources))
1067+
{
1068+
ApplyMergeMatched(context, destinationTable, sourceView, whenClauses, pageIndex, slotIndex, targetValues, sourceRows, matchedSources, ResolveCombined, pendingUpdates, pendingDeletes);
1069+
}
1070+
else
1071+
{
1072+
var chosen = PickClause(whenClauses, WhenClauseKind.NotMatchedBySource, targetValues, sourceValues: null, context.Batch, ResolveCombined);
1073+
if (chosen is not null)
1074+
ApplyChosenMatchedAction(context, destinationTable, sourceView, chosen, pageIndex, slotIndex, targetValues, sourceValues: null, ResolveCombined, pendingUpdates, pendingDeletes);
1075+
}
1076+
}
1077+
}
1078+
else
1079+
{
1080+
// No NOT MATCHED BY SOURCE: visit only matched targets, restoring
1081+
// heap order via the (page, slot) sort so the apply order matches
1082+
// the scan path's.
1083+
foreach (var address in matchedByTarget.Keys.OrderBy(a => a.Page).ThenBy(a => a.Slot))
1084+
{
1085+
var targetValues = DecodeFullRow(destinationTable, destinationTable.Heap.ReadSlotBytes(address.Page, address.Slot)!);
1086+
EvaluateComputedColumns(destinationTable, targetValues, context.Batch);
1087+
ApplyMergeMatched(context, destinationTable, sourceView, whenClauses, address.Page, address.Slot, targetValues, sourceRows, matchedByTarget[address], ResolveCombined, pendingUpdates, pendingDeletes);
1088+
}
10681089
}
10691090
}
10701091
else
@@ -1170,6 +1191,35 @@ SqlValue ResolveCombined(SqlValue[]? targetValues, SqlValue[]? sourceValues, Mul
11701191
return null;
11711192
}
11721193

1194+
// Applies the WHEN MATCHED action to one matched target row, given the source
1195+
// rows it matched (in source-index order, first wins). Picks the first
1196+
// applicable MATCHED clause, enforces the multiple-source-match guard
1197+
// (Msg 8672 for UPDATE), and queues the action. Shared by both inverted-seek
1198+
// apply passes (matched-only and the NOT MATCHED BY SOURCE complement scan).
1199+
private static void ApplyMergeMatched(
1200+
ParserContext context,
1201+
HeapTable destinationTable,
1202+
View? sourceView,
1203+
List<WhenClause> whenClauses,
1204+
int pageIndex,
1205+
int slotIndex,
1206+
SqlValue[] targetValues,
1207+
List<SqlValue[]> sourceRows,
1208+
List<int> matchedSources,
1209+
Func<SqlValue[]?, SqlValue[]?, MultiPartName, SqlValue> resolveCombined,
1210+
List<(int Page, int Slot, SqlValue[] OldValues, SqlValue[] NewValues, SqlValue[]? SourceValues)> pendingUpdates,
1211+
List<(int Page, int Slot, SqlValue[] OldValues, SqlValue[]? SourceValues)> pendingDeletes)
1212+
{
1213+
var sourceValues = sourceRows[matchedSources[0]];
1214+
var chosen = PickClause(whenClauses, WhenClauseKind.Matched, targetValues, sourceValues, context.Batch, resolveCombined);
1215+
if (chosen is null)
1216+
return;
1217+
if (chosen.Action == MergeActionKind.Update && matchedSources.Count > 1)
1218+
throw SimulatedSqlException.MergeMultiMatch();
1219+
1220+
ApplyChosenMatchedAction(context, destinationTable, sourceView, chosen, pageIndex, slotIndex, targetValues, sourceValues, resolveCombined, pendingUpdates, pendingDeletes);
1221+
}
1222+
11731223
private static void ApplyChosenMatchedAction(
11741224
ParserContext context,
11751225
HeapTable destinationTable,

docs/claude/dml.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Creates a destination table from the projection's inferred schema, then copies r
7474
`Simulation.Merge.cs:ExecuteMerge` is a single-pass walk:
7575

7676
1. **Materialize source** once into `List<SqlValue[]>` via the parse-time `Func<BatchContext, List<SqlValue[]>>` materializer. `VALUES`-form evaluates the tuple expressions; `SELECT`-form runs `Selection.Execute` and decodes via `RowDecoder`; the bare-table / view form iterates the underlying heap or view selection respectively, then runs `EvaluateComputedColumns` per row so source-side computed columns are observable from the ON predicate / SET / INSERT projections.
77-
2. **Phase A — target × source**: for each target heap row, enumerate source rows; ON evaluates with a combined resolver wired to both target alias and source alias. Multiple-match collection feeds the Msg 8672 guard. For each target with ≥ 1 match, walk WHEN MATCHED clauses; first clause whose `AND` is satisfied (or absent) wins. For each target with 0 matches, walk WHEN NOT MATCHED BY SOURCE clauses the same way. Action gets queued (`pendingInserts` / `pendingUpdates` / `pendingDeletes`) along with the `(page, slot)` address + pre-update and post-update row snapshots. **Seek-accelerated when applicable**: when the ON carries a seekable target equality, there's no WHEN NOT MATCHED BY SOURCE clause, and the target isn't a view, Phase A inverts — it seeks matching targets per source row (`Selection.TryPrepareMergeTargetSeek`) and visits only matched targets, re-running the full ON per candidate (residual filter), first-source-wins and heap order preserved. ~9× faster on a large target; see [`indexes.md`](indexes.md#merge-target-seeking-loop-inversion). Declines to the full scan otherwise.
77+
2. **Phase A — target × source**: for each target heap row, enumerate source rows; ON evaluates with a combined resolver wired to both target alias and source alias. Multiple-match collection feeds the Msg 8672 guard. For each target with ≥ 1 match, walk WHEN MATCHED clauses; first clause whose `AND` is satisfied (or absent) wins. For each target with 0 matches, walk WHEN NOT MATCHED BY SOURCE clauses the same way. Action gets queued (`pendingInserts` / `pendingUpdates` / `pendingDeletes`) along with the `(page, slot)` address + pre-update and post-update row snapshots. **Seek-accelerated when applicable**: when the ON carries a seekable target equality and the target isn't a view, the match phase inverts — it seeks matching targets per source row (`Selection.TryPrepareMergeTargetSeek`), re-running the full ON per candidate (residual filter), first-source-wins and heap order preserved. With no WHEN NOT MATCHED BY SOURCE clause it then visits only matched targets; with one it walks the heap once applying the precomputed matches (BY-SOURCE for the rest) — dropping the inner source loop either way. ~9× faster on a large target; see [`indexes.md`](indexes.md#merge-target-seeking-loop-inversion). Declines to the full scan for a view target or a non-seekable ON.
7878
3. **Phase B — unmatched sources**: for each source row that didn't match any target, the single WHEN NOT MATCHED BY TARGET clause's AND condition is evaluated; if true, queue an INSERT.
7979
4. **Phase C — commit**: PK / UNIQUE validation runs on the union of pending inserts + updates via `EnforceKeyConstraintsForUpdate` (inserts use sentinel `(-1, i)` addresses). If a violation surfaces, every queued mutation is abandoned and the statement-atomic undo log already captures the no-heap-writes state. Then deletes tombstone, updates rewrite, inserts append, in that order.
8080
5. **Phase D — OUTPUT**: walk queued INSERT rows → UPDATE rows → DELETE rows; the `MergeOutputProjection` resolves `INSERTED.col` / `DELETED.col` / source-alias / `$action`. For each row, the unmatched side projects all-NULL.

0 commit comments

Comments
 (0)