Skip to content

Commit 51e98be

Browse files
committed
Route foreign-key enforcement through the seek cache: extract the per-Heap index out of Selection into a shared HeapSeekCache (with SqlValueKey promoted to a top-level type), and seek parent-existence and cascade child-lookups instead of scanning.
1 parent 5ced3b5 commit 51e98be

9 files changed

Lines changed: 676 additions & 431 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contrac
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.
110110
- `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). See [`indexes.md`](docs/claude/indexes.md) for the journal mechanics, decline rules, and the residual-WHERE correctness invariant.
111-
- `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`. Referential-action, cascade-cycle, PK/UNIQUE-target, and NULL-skip rules + Msg numbers in [`foreign-keys.md`](docs/claude/foreign-keys.md).
111+
- `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
114114
Three entry points share one per-connection undo log: implicit (statement-level atomicity), SqlClient API (`BeginTransaction()`/`Commit()`/`Rollback()`), SQL-text (`BEGIN`/`COMMIT`/`ROLLBACK`/`SAVE TRANSACTION`).

SqlServerSimulator.Tests.Internal/IndexSeekTests.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,4 +1517,100 @@ public void RangeCorrectUnderManyMutations_MatchesIndependentBaseline()
15171517
actual.Sort();
15181518
AreEqual(string.Join(",", expected), string.Join(",", actual));
15191519
}
1520+
1521+
// ---- foreign-key enforcement rides the same per-Heap seek cache (shared via
1522+
// HeapSeekCache.For). An INSERT … VALUES does no query-path seek, so any
1523+
// CacheBuild / CacheReplay during a child insert is the parent-existence
1524+
// seek; a no-WHERE parent delete does no query-path seek either, isolating
1525+
// the cascade's child-lookup seek. ----
1526+
1527+
[TestMethod]
1528+
public void ForeignKeyChildInsert_SeeksParentExistence()
1529+
{
1530+
var c = new Simulation().CreateDbConnection();
1531+
c.Open();
1532+
Exec(c, """
1533+
create table p (id int not null primary key);
1534+
create table ch (id int not null primary key, pid int not null references p(id));
1535+
insert p values (1), (2), (3)
1536+
""");
1537+
IndexSeekDiagnostics.Sink = [];
1538+
try
1539+
{
1540+
Exec(c, "insert ch values (10, 2)");
1541+
// INSERT VALUES has no SELECT/WHERE to seek, so this is the FK
1542+
// parent-existence seek building the parent's id index.
1543+
Contains("CacheBuild", IndexSeekDiagnostics.Sink);
1544+
}
1545+
finally
1546+
{
1547+
IndexSeekDiagnostics.Sink = null;
1548+
}
1549+
}
1550+
1551+
[TestMethod]
1552+
public void ForeignKeyChildInsert_ReplaysParentDelta_NoWarmup()
1553+
{
1554+
var c = new Simulation().CreateDbConnection();
1555+
c.Open();
1556+
Exec(c, """
1557+
create table p (id int not null primary key);
1558+
create table ch (id int not null primary key, pid int not null references p(id));
1559+
insert p values (1), (2)
1560+
""");
1561+
Exec(c, "insert ch values (10, 1)"); // builds the parent seek cache
1562+
IndexSeekDiagnostics.Sink = [];
1563+
try
1564+
{
1565+
Exec(c, "insert p values (3)"); // moves the parent's mutation generation
1566+
Exec(c, "insert ch values (11, 3)"); // parent-existence seek replays the delta
1567+
Contains("CacheReplay", IndexSeekDiagnostics.Sink);
1568+
DoesNotContain("CacheBuild", IndexSeekDiagnostics.Sink);
1569+
}
1570+
finally
1571+
{
1572+
IndexSeekDiagnostics.Sink = null;
1573+
}
1574+
}
1575+
1576+
[TestMethod]
1577+
public void ForeignKeyCascadeDelete_SeeksChildren()
1578+
{
1579+
var c = new Simulation().CreateDbConnection();
1580+
c.Open();
1581+
Exec(c, """
1582+
create table p (id int not null primary key);
1583+
create table ch (id int not null primary key, pid int not null references p(id) on delete cascade);
1584+
insert p values (1), (2);
1585+
insert ch values (10, 1), (11, 1), (12, 2)
1586+
""");
1587+
IndexSeekDiagnostics.Sink = [];
1588+
try
1589+
{
1590+
// No WHERE → the parent delete itself doesn't seek, so the cache
1591+
// activity is the cascade seeking children by their pid.
1592+
Exec(c, "delete from p");
1593+
Contains("CacheBuild", IndexSeekDiagnostics.Sink);
1594+
}
1595+
finally
1596+
{
1597+
IndexSeekDiagnostics.Sink = null;
1598+
}
1599+
AreEqual(0, Convert.ToInt32(ReadVal(c, "select count(*) from ch")));
1600+
}
1601+
1602+
[TestMethod]
1603+
public void ForeignKeyCascadeDelete_SelectiveParent_CorrectChildrenRemain()
1604+
{
1605+
var c = new Simulation().CreateDbConnection();
1606+
c.Open();
1607+
Exec(c, """
1608+
create table p (id int not null primary key);
1609+
create table ch (id int not null primary key, pid int not null references p(id) on delete cascade);
1610+
insert p values (1), (2), (3);
1611+
insert ch values (10, 1), (11, 1), (12, 2), (13, 3)
1612+
""");
1613+
Exec(c, "delete from p where id = 1");
1614+
AreEqual("12,13", Seq(ReadRows(c, "select id from ch order by id")));
1615+
}
15201616
}

0 commit comments

Comments
 (0)