Skip to content

Commit 86703ce

Browse files
committed
Invert MERGE's target × source scan into a per-source-row target seek when the ON carries a seekable target equality.
1 parent c98b8f8 commit 86703ce

6 files changed

Lines changed: 255 additions & 38 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contrac
107107

108108
### Constraints
109109
- `CHECK`: inline single-column and table-level forms; Msg 547 per row on definitely-false predicate. Inline column-level CHECK predicates may only reference their owning column — peer references raise **Msg 8141** at CREATE TABLE (probe-confirmed verbatim wording). The walker is structural via `Expression.VisitColumnReferences` + `BooleanExpression.VisitOperandExpressions`; coverage is currently limited to common container subclasses (`Reference`, `Parenthesized`, `TwoSidedExpression`, `Cast`, `Length`) — peer refs buried in less-common containers (`DATEPART`, `SUBSTRING`, nested `CASE`, etc.) silently escape the CREATE-TABLE check and surface at INSERT instead. Table-level CHECK has no peer restriction.
110-
- `PRIMARY KEY` / `UNIQUE` / secondary `CREATE INDEX`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up), identical across keys and non-clustered indexes (built from heap-row bytes keyed by the index's ordinals): equality / IN on the longest leading prefix, range (`>`/`<`/`BETWEEN`) on a leading key column, ORDER BY elimination — single or multi-column leading-prefix sort (all NOT NULL, all one direction), including an equality-prefix continued by the order columns (`WHERE a = @x ORDER BY b`) — and keyset (seek-method) pagination (`WHERE a > @x OR (a = @x AND b > @y) ORDER BY a, b` seeks past the cursor). The same equality / range narrowing accelerates **single-table `UPDATE` / `DELETE` target scans** (`Selection.SeekMutationTarget`; the mutation loop's full-predicate re-check is the residual filter, and it X-locks only the rows it commits so the lock footprint is unchanged) — `MERGE` still full-scans (its `ON` correlates target to source, so it needs loop inversion). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
110+
- `PRIMARY KEY` / `UNIQUE` / secondary `CREATE INDEX`: linear scan (O(N) per insert); no B-tree. Reads get **incrementally-maintained** per-`Heap` seek acceleration (no per-mutation warm-up), identical across keys and non-clustered indexes: equality / IN on the longest leading prefix, range on a leading key column, ORDER BY elimination, and keyset pagination. The same narrowing accelerates `UPDATE` / `DELETE` target scans and `MERGE` (via loop inversion). See [`indexes.md`](docs/claude/indexes.md) for the seek shapes, mutation/MERGE seeking, journal mechanics, decline rules, and the residual-WHERE correctness invariant.
111111
- `FOREIGN KEY`: inline / table-level / named forms; all four referential actions on `ON DELETE` / `ON UPDATE`; enforced at INSERT / UPDATE / DELETE / MERGE; full `sys.foreign_keys` / `sys.foreign_key_columns`. Enforcement **seeks the shared `HeapSeekCache`** (the same per-`Heap` index the query path uses): child-insert parent-existence probes the parent's PK/UNIQUE; parent-delete/update cascades seek the child's FK columns — both verifying candidates against live bytes (no residual WHERE), with a full-scan fallback only for non-stored key columns. Referential-action, cascade-cycle, PK/UNIQUE-target, and NULL-skip rules + Msg numbers in [`foreign-keys.md`](docs/claude/foreign-keys.md).
112112

113113
### Transactions

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1714,4 +1714,93 @@ public void DeleteSeek_NoMatch_LeavesTableIntact()
17141714
Exec(c, "delete from t where id = 99");
17151715
AreEqual("1,2,3", Seq(ReadRows(c, "select id from t order by id")));
17161716
}
1717+
1718+
// ---- MERGE inverts its target × source scan into a per-source-row target
1719+
// seek (Selection.TryPrepareMergeTargetSeek) when the ON carries a seekable
1720+
// target equality, no NOT MATCHED BY SOURCE clause forces every target to be
1721+
// visited, and the target isn't a view. A MERGE does no query-path seek of
1722+
// its own, so a CacheBuild during one is the inverted target seek; the
1723+
// declined cases (NMBS clause, non-seekable ON) keep the full scan and never
1724+
// touch the cache. ----
1725+
1726+
private static SimulatedDbConnection FreshTarget()
1727+
{
1728+
var c = new Simulation().CreateDbConnection();
1729+
c.Open();
1730+
Exec(c, """
1731+
create table tgt (id int not null primary key, v int not null);
1732+
insert tgt values (1, 10), (2, 20), (3, 30)
1733+
""");
1734+
return c;
1735+
}
1736+
1737+
[TestMethod]
1738+
public void MergeOnPrimaryKey_SeeksTarget()
1739+
{
1740+
var c = FreshTarget();
1741+
var trace = ExecTraced(c, """
1742+
merge tgt as t
1743+
using (values (2, 200), (4, 400)) as s(id, v) on t.id = s.id
1744+
when matched then update set v = s.v
1745+
when not matched then insert (id, v) values (s.id, s.v);
1746+
""");
1747+
Contains("CacheBuild", trace);
1748+
AreEqual(4, Convert.ToInt32(ReadVal(c, "select count(*) from tgt")));
1749+
AreEqual(200, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2"))); // matched → updated
1750+
AreEqual(400, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 4"))); // not matched → inserted
1751+
AreEqual(10, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 1"))); // untouched
1752+
}
1753+
1754+
[TestMethod]
1755+
public void MergeNotMatchedBySource_FullScans()
1756+
{
1757+
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.
1760+
var trace = ExecTraced(c, """
1761+
merge tgt as t
1762+
using (values (2, 200)) as s(id, v) on t.id = s.id
1763+
when matched then update set v = s.v
1764+
when not matched by source then delete;
1765+
""");
1766+
DoesNotContain("CacheBuild", trace);
1767+
AreEqual("2", Seq(ReadRows(c, "select id from tgt order by id"))); // 1 and 3 deleted
1768+
AreEqual(200, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2")));
1769+
}
1770+
1771+
[TestMethod]
1772+
public void MergeSeek_ResidualOnTerm_Filters()
1773+
{
1774+
var c = new Simulation().CreateDbConnection();
1775+
c.Open();
1776+
Exec(c, """
1777+
create table tgt (id int not null primary key, active int not null, v int not null);
1778+
insert tgt values (1, 1, 10), (2, 0, 20)
1779+
""");
1780+
// ON seeks id; the residual t.active = 1 (re-checked per candidate)
1781+
// excludes id = 2, so its source row matches nothing and v stays 20.
1782+
var trace = ExecTraced(c, """
1783+
merge tgt as t
1784+
using (values (1, 100), (2, 200)) as s(id, v) on t.id = s.id and t.active = 1
1785+
when matched then update set v = s.v;
1786+
""");
1787+
Contains("CacheBuild", trace);
1788+
AreEqual(100, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 1")));
1789+
AreEqual(20, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2")));
1790+
}
1791+
1792+
[TestMethod]
1793+
public void MergeNonSeekableOn_FullScans()
1794+
{
1795+
var c = FreshTarget();
1796+
// ON on an unindexed expression (v, no key) — nothing seekable, so the
1797+
// inversion declines and the scan stands.
1798+
var trace = ExecTraced(c, """
1799+
merge tgt as t
1800+
using (values (20, 999)) as s(v, nv) on t.v = s.v
1801+
when matched then update set v = s.nv;
1802+
""");
1803+
DoesNotContain("CacheBuild", trace);
1804+
AreEqual(999, Convert.ToInt32(ReadVal(c, "select v from tgt where id = 2")));
1805+
}
17171806
}

SqlServerSimulator/Parser/Selection.Execution.IndexSeek.cs

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,7 +1329,7 @@ private static IEnumerable<byte[]> MaterializeWithLockChecks(
13291329
internal static IEnumerable<(int Page, int Slot, byte[] Bytes)>? SeekMutationTarget(
13301330
HeapTable table, BooleanExpression where, BatchContext batch)
13311331
{
1332-
var source = BuildBaseTableSeekSource(table);
1332+
var source = BuildBaseTableSeekSource(table, table.Name);
13331333

13341334
var conjuncts = new List<BooleanExpression>();
13351335
where.CollectConjuncts(conjuncts);
@@ -1360,18 +1360,82 @@ private static IEnumerable<byte[]> MaterializeWithLockChecks(
13601360
return null;
13611361
}
13621362

1363-
// Minimal single-source view of a base table for the mutation seek: real
1364-
// column names + storage metadata so TryIdentifyIndexableColumn resolves the
1365-
// WHERE's column references, qualified by the table's own name (the only
1366-
// qualifier a single-table UPDATE / DELETE WHERE can use). The Rows stream is
1367-
// unused — the seek reads addresses straight from the heap's cache.
1368-
private static FromSource BuildBaseTableSeekSource(HeapTable table)
1363+
/// <summary>
1364+
/// A per-source-row target seeker for a MERGE whose <c>ON</c> carries a
1365+
/// seekable equality on the target's leading key / index prefix — returns
1366+
/// <c>null</c> when no such equality exists (caller keeps its
1367+
/// <c>target × source</c> scan). The <c>ON</c> conjunct <c>t.k = s.k</c> is
1368+
/// the correlated-seek shape: <c>t.k</c> resolves to the (single-source)
1369+
/// target so it's the column side, <c>s.k</c> doesn't so it's a stable outer
1370+
/// value — exactly what the SELECT path's correlated subquery seek already
1371+
/// recognizes (<c>allowCorrelatedColumnValue: true</c>). The returned delegate
1372+
/// takes one source row's resolver and yields the matching target rows; a NULL
1373+
/// or non-matching probe yields nothing (a valid empty match for that source).
1374+
/// The structural test (does some key's lead column carry an equality?) is run
1375+
/// once here, so per-source the delegate only evaluates probes and seeks. The
1376+
/// caller still re-runs the full <c>ON</c> predicate per candidate — the seek
1377+
/// keys on the equality prefix only, so a residual <c>ON</c> term
1378+
/// (<c>… AND t.active = 1</c>) and a stale cache entry are both filtered there.
1379+
/// </summary>
1380+
internal static Func<Func<MultiPartName, SqlValue>, IEnumerable<(int Page, int Slot, byte[] Bytes)>>? TryPrepareMergeTargetSeek(
1381+
HeapTable table, string? targetQualifier, BooleanExpression on, BatchContext batch)
1382+
{
1383+
var source = BuildBaseTableSeekSource(table, targetQualifier);
1384+
1385+
var conjuncts = new List<BooleanExpression>();
1386+
on.CollectConjuncts(conjuncts);
1387+
1388+
var equalities = new Dictionary<int, Expression[]>();
1389+
foreach (var conjunct in conjuncts)
1390+
{
1391+
if (conjunct.TryGetEqualityOperands(out var left, out var right))
1392+
{
1393+
_ = TryRecordColumnEquality(source, left, right, equalities, allowCorrelatedColumnValue: true)
1394+
|| TryRecordColumnEquality(source, right, left, equalities, allowCorrelatedColumnValue: true);
1395+
continue;
1396+
}
1397+
if (conjunct.TryGetEqualityFamily(out var family))
1398+
_ = TryRecordEqualityFamily(source, family, equalities, allowCorrelatedColumnValue: true);
1399+
}
1400+
1401+
return HasSeekableLeadingPrefix(table, equalities) ? Seek : null;
1402+
1403+
IEnumerable<(int Page, int Slot, byte[] Bytes)> Seek(Func<MultiPartName, SqlValue> outerResolver) =>
1404+
TryComputeEqualityCandidates(source, table, batch, outerResolver, equalities, out var candidates, out _)
1405+
? MaterializeMutationCandidates(table, candidates)
1406+
: [];
1407+
}
1408+
1409+
// True when some key / index's leading column carries a recorded equality —
1410+
// the structural precondition for a seek (independent of probe values, so a
1411+
// per-source NULL probe later reads as an empty match, not "unseekable").
1412+
private static bool HasSeekableLeadingPrefix(HeapTable table, Dictionary<int, Expression[]> equalities)
1413+
{
1414+
foreach (var key in table.KeyConstraints)
1415+
{
1416+
if (key.StorageOrdinals.Length > 0 && equalities.ContainsKey(key.StorageOrdinals[0]))
1417+
return true;
1418+
}
1419+
foreach (var index in table.Indexes)
1420+
{
1421+
if (index.KeyColumns.Length > 0 && equalities.ContainsKey(index.KeyColumns[0].StorageOrdinal))
1422+
return true;
1423+
}
1424+
return false;
1425+
}
1426+
1427+
// Minimal single-source view of a base table for a seek: real column names +
1428+
// storage metadata so TryIdentifyIndexableColumn resolves the predicate's
1429+
// column references, under the given qualifier (the table's own name for a
1430+
// single-table UPDATE / DELETE, the target alias for a MERGE). The Rows stream
1431+
// is unused — the seek reads addresses straight from the heap's cache.
1432+
private static FromSource BuildBaseTableSeekSource(HeapTable table, string? qualifier)
13691433
{
13701434
var columnNames = new string[table.Columns.Length];
13711435
for (var i = 0; i < columnNames.Length; i++)
13721436
columnNames[i] = table.Columns[i].Name;
13731437
return new FromSource(
1374-
qualifier: table.Name,
1438+
qualifier: qualifier,
13751439
columnNames: columnNames,
13761440
columns: table.Columns,
13771441
storedSchema: table.StoredColumns,

0 commit comments

Comments
 (0)