Skip to content

Commit 8ea005a

Browse files
committed
Foreign keys: full inline + table-level CREATE TABLE grammar ([CONSTRAINT name] REFERENCES other(col) on the column being declared / [CONSTRAINT name] FOREIGN KEY (cols) REFERENCES other(cols)) with the complete ON DELETE / ON UPDATE action quartet — NO ACTION (default) / CASCADE / SET NULL / SET DEFAULT — backed by a new Storage/ForeignKey.cs (name + object id + both tables + child/referenced full-ordinal arrays + two ReferentialAction enum values matching sys.foreign_keys codes 0-3 + IsSystemNamed) and two new mutable lists on HeapTable (OutgoingForeignKeys child-side / IncomingForeignKeys parent-side) wired symmetrically by ResolveForeignKeys after the table is in its schema dict so self-references resolve. Runtime enforcement in a new Simulation.ForeignKeys.cs: EnforceOutgoingForeignKeys walks INSERT/UPDATE/MERGE post-rows with NULL-in-any-FK-column skip (probe-confirmed for partial-NULL composites too) and raises Msg 547 with probe-confirmed wording — "FOREIGN KEY constraint" / "FOREIGN KEY SAME TABLE" for self-refs / column phrase dropped for composites; EnforceIncomingForeignKeys (DELETE) and EnforceIncomingFkOnUpdate (UPDATE, threading parent old/new pairs so ON UPDATE CASCADE rewrites land correctly) raise Msg 547 with "REFERENCE constraint" wording on NO_ACTION or recursively descend for CASCADE / SET NULL / SET DEFAULT, capped at MaxCascadeDepth = 32. CREATE-time validation: referenced columns must form a PK/UNIQUE (Msg 1776, multiset compare against KeyConstraints); cascade-cycle / multiple-path graph walk over the existing FK graph plus already-resolved-in-this-statement FKs raises Msg 1785, with the new FK excluded from its own reachability check and self-reference + cascading action short-circuited as the explicit 1-cycle. DROP TABLE on a referenced parent → Msg 3726; successful child DROP detaches outgoing FKs from each parent's incoming list. Auto-name follows the project's FNV-1a convention: FK__<table>__<col>__<hex> single / FK__<table>__<hex> composite. Catalog: sys.objects interleaves 'F ' / FOREIGN_KEY_CONSTRAINT rows after each child table; new sys.foreign_keys (22 cols matching the probed shape, action codes + descriptions + is_system_named / is_disabled / is_not_trusted) + sys.foreign_key_columns (6 cols, one row per pair). Grammar plumbing limited to a new Action contextual keyword (everything else reuses existing reserved tokens). EF Core 10's HasOne / WithMany / HasForeignKey / OnDelete(DeleteBehavior.Cascade) end-to-end through SaveChanges. New ForeignKeyTests covering inline / table-level / composite / named / self-ref / NULL-skip / all four actions on DELETE+UPDATE / cascade chains / MERGE / multi-row rollback / Msg 1776 + 1785 + 3726 + 547 wording variants / catalog projection, plus tx-rollback tests confirming explicit ROLLBACK restores the entire CASCADE / SET NULL / SET DEFAULT / ON UPDATE CASCADE chain, SAVE TRAN + ROLLBACK TRAN <name> rewinds the cascade while keeping the outer tx alive, and statement-atomic rollback restores every row when a multi-hop chain hits a NO ACTION leaf (works without code changes — every cascade mutation threads through the active UndoLog); new EFCoreForeignKey validates the LINQ-side relationship round-trip. New SqlServerSimulator.Tests/CLAUDE.md documents the dense multi-statement-raw-string test shape so future tests land dense first time. CLAUDE.md "Not modeled" FK bullet removed; new Constraints entry + trigger phrase pointing at the new docs/claude/foreign-keys.md covering grammar / storage / validation / enforcement / catalog / EF Core / fidelity gaps (ALTER TABLE ADD/DROP CONSTRAINT deferred; OBJECT_ID(name, 'F') returns NULL; key_index_id always 1; SET DEFAULT-to-NULL on NOT NULL surfaces at runtime as Msg 515 not CREATE-time Msg 1789).
1 parent a727a2c commit 8ea005a

18 files changed

Lines changed: 2414 additions & 24 deletions

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Five scopes, one home each. **Add new state to whichever class matches its true
6262

6363
- **`Simulation`** = server / instance. Process-shared system tables (`SystemHeapTables`), `NEWSEQUENTIALID` anchor + counter, the `Databases` dictionary. Public surface (`Simulation` ctor + `CreateDbConnection()`) stays on this class.
6464
- **`Database`** (internal) = one database hosted by the server instance. `Schemas` (named-schema dict, pre-seeded with `dbo`), `CompatibilityLevel`, `VerboseTruncationWarnings`, `rowVersionCounter` (per-DB `@@DBTS`). Every `Simulation` ships with one entry named `Simulation.DefaultDatabaseName` (`"simulated"`); a future `USE <db>` adds entries to the dictionary.
65-
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict), `TableTypes` (per-schema user-defined-table-type dict — `CREATE TYPE … AS TABLE` populates; separate namespace from the object dicts, see `docs/claude/table-valued-parameters.md`), `Sequences` (per-schema sequence-object dict — `CREATE SEQUENCE` populates, shares the object namespace with tables/views/funcs/procs, see `docs/claude/sequences.md`), `Triggers` (per-schema DML-trigger dict — `CREATE TRIGGER` populates; shares the object namespace, see `docs/claude/triggers.md`). Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`, `DECLARE @t audit.MyType`, `NEXT VALUE FOR audit.seq`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
65+
- **`Schema`** (internal) = one namespace inside a database. `HeapTables` (per-schema table dict), `Functions` (UDFs — abstract `UserDefinedFunction` keyed by name, runtime-typed as either `ScalarFunction` or `InlineTableValuedFunction`), `Views` (per-schema view dict), `Procedures` (per-schema stored-procedure dict), `TableTypes` (per-schema user-defined-table-type dict — `CREATE TYPE … AS TABLE` populates; separate namespace from the object dicts, see `docs/claude/table-valued-parameters.md`), `Sequences` (per-schema sequence-object dict — `CREATE SEQUENCE` populates, shares the object namespace with tables/views/funcs/procs, see `docs/claude/sequences.md`), `Triggers` (per-schema DML-trigger dict — `CREATE TRIGGER` populates; shares the object namespace, see `docs/claude/triggers.md`). FOREIGN KEY constraints live on `HeapTable.OutgoingForeignKeys` (child side) and `HeapTable.IncomingForeignKeys` (parent side) — see `docs/claude/foreign-keys.md`. Schema-qualified references (`SELECT * FROM audit.t`, `SELECT audit.fn(x)`, `FROM audit.tvf(x)`, `FROM audit.view1`, `EXEC audit.proc1`, `DECLARE @t audit.MyType`, `NEXT VALUE FOR audit.seq`) route through `Database.Schemas["audit"]`; unqualified references fall back to `Database.DefaultSchemaName` (`"dbo"`).
6666
- **`SimulatedDbConnection`** = session. `CurrentDatabase` pointer, `CurrentTransaction`, `LastIdentity` (`SCOPE_IDENTITY()` / `@@IDENTITY`), `LastStatementRowCount` (`@@ROWCOUNT`), `LastErrorNumber` (`@@ERROR`), `NestingLevel` (UDF / proc / trigger / view recursion depth, capped at 32), `IdentityInsertTable`, `TraceFlags`, `IsVerboseTruncationActive()`, `TempTables` (per-session `#foo` dictionary, cleared on `Dispose`).
6767
- **`BatchContext`** (internal, in `Parser/`) = one command execution. Owns the `ParserContext` (parse-time-only scratch — `Token`, `AggregateCollector`, `WindowCollector`, `OuterTypeResolver`, `CteBindings`, `InDefaultClause`, `AllowsWindowExpressions`) and holds batch-lifetime runtime state: `Variables`, `TableVariables` (per-batch `DECLARE @t TABLE`–backed `HeapTable` dict, shared namespace with scalar `Variables` for the Msg-134 uniqueness check), `CurrentUndoLog` (per-tx/per-statement, regular tables), `CurrentTableVarUndoLog` (per-statement-only, `@t` writes — kept disjoint from the tx-scoped log so `ROLLBACK TRAN` skips `@t` while statement-atomic rollback covers it), `UdfFrame` (non-null when this batch is a scalar-UDF body being dispatched — gates value-form `RETURN` and lands the return value for the caller), `ProcFrame` (non-null when this batch is a stored-procedure body — same gate for value-form `RETURN`, plus a return-code slot the caller reads), plus the per-statement frame `CurrentStatement`. Exposes `TryResolveTable(MultiPartName)` — the routing rule that dispatches `#foo` leaves to `Connection.TempTables` regardless of qualifier and `@t` leaves to `TableVariables` (1-part only — `dbo.@t` returns false; the caller raises Msg 102 at parse via `acceptTableVariable` gating); everything else routes through the named schema (or `dbo` for an unqualified reference), with `SystemHeapTables` reachable only as a flat 1-part fallback. `TryResolveFunction(MultiPartName)` resolves 2-/3-part dotted names against the named schema's `Functions` dict; 1-part names return false (real SQL Server rejects bare UDF calls with Msg 195). `TryResolveProcedure(MultiPartName)` resolves through the same schema-lookup path but accepts 1-part names (probe-confirmed: `EXEC p1` finds `dbo.p1`). `TryResolveTableType(MultiPartName)` resolves user-defined table types against the named schema's `TableTypes` dict and falls back to `dbo` for 1-part names (DECLARE @t MyType / TVP-parameter-type lookup). `TryResolveSchema(MultiPartName)` exposes the dict-bearing schema for CREATE / DROP / TRUNCATE / SELECT INTO. `ParseObjectName(ParserContext, bool acceptTableVariable = false)` parses the 1–4-segment dotted form, leaves cursor on the last name segment (standard parser contract), and compresses empty middle segments (so `tempdb..#foo` returns a 2-part name); the `acceptTableVariable` opt-in routes `@t` to a 1-part-with-`@`-prefix leaf for DML / FROM-source sites, and rejects `@t` everywhere else (so ALTER TABLE / DROP TABLE / TRUNCATE / CREATE / SELECT INTO surface Msg 102 matching probe). Threaded explicitly into every `Expression.Run(RuntimeContext runtime)` call via `runtime.Batch`. Scalar-UDF and procedure invocation each allocate a child `BatchContext` via the corresponding body constructor: parameters pre-seed `Variables`, the matching frame is set, the body source text (captured at CREATE FUNCTION / CREATE PROCEDURE time) is re-tokenized through a synthesized `SimulatedDbCommand`, and the same dispatch loop runs the body. UDF bodies discard yielded result sets at the call site (Msg 444 territory); procedure bodies forward them through to the outer caller's iterator.
6868
- **`StatementContext`** (internal, in `Parser/`) = the dispatch loop's per-statement frame. Allocated once per batch and overwritten in place at the top of each iteration; holds `UtcNow` (the per-statement-freeze the time scalars read). Stored-proc EXEC / TRY-CATCH frames slot in here when added.
@@ -105,6 +105,7 @@ INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS
105105
### Constraints
106106
- `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.
107107
- `PRIMARY KEY` / `UNIQUE`: linear scan (O(N) per insert); no B-tree.
108+
- `FOREIGN KEY`: inline `col REFERENCES other(col)` + table-level `FOREIGN KEY (cols) REFERENCES other(cols)` + named `CONSTRAINT name`; all four referential actions on both `ON DELETE` / `ON UPDATE` (`NO ACTION` default / `CASCADE` / `SET NULL` / `SET DEFAULT`); enforcement at INSERT / UPDATE / DELETE / MERGE; cascade-cycle rejection at CREATE (Msg 1785); referenced columns must form PK or UNIQUE (Msg 1776); NULL in FK column skips check (including partial NULL in composite); DROP TABLE on referenced parent → Msg 3726; full `sys.foreign_keys` (22 cols) + `sys.foreign_key_columns` (6 cols); `sys.objects.type = 'F '`. See `docs/claude/foreign-keys.md`.
108109

109110
### Transactions
110111
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`).
@@ -149,6 +150,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149150
- **Touching `CREATE TYPE … AS TABLE`, `DECLARE @t MyType`, TVP procedure parameters / `READONLY`, or the ADO.NET `DbParameter.TypeName` extension (DataTable / IDataReader as a TVP value source)**[`docs/claude/table-valued-parameters.md`](docs/claude/table-valued-parameters.md).
150151
- **Touching `CREATE SEQUENCE` / `ALTER SEQUENCE` / `DROP SEQUENCE`, `NEXT VALUE FOR`, the per-row dedup mechanism (`BatchContext.CurrentRowStamp` / `SequenceRowCache`), or `sys.sequences`**[`docs/claude/sequences.md`](docs/claude/sequences.md).
151152
- **Touching `CREATE TRIGGER` / `ALTER TRIGGER` / `DROP TRIGGER` / `DISABLE`/`ENABLE TRIGGER`, the `INSERTED` / `DELETED` pseudo-table materialization, the `TriggerFrame` / `FiringTriggerIds` recursion guard, `TRIGGER_NESTLEVEL()`, or `sys.triggers`**[`docs/claude/triggers.md`](docs/claude/triggers.md).
153+
- **Touching `FOREIGN KEY` parsing (`REFERENCES` inline / `FOREIGN KEY (cols) REFERENCES other(cols)` table-level), referential-action wiring (`ON DELETE` / `ON UPDATE` × `NO ACTION` / `CASCADE` / `SET NULL` / `SET DEFAULT`), the FK enforcement loop in `Simulation.ForeignKeys.cs`, the `HeapTable.OutgoingForeignKeys` / `IncomingForeignKeys` lists, cascade-cycle detection (Msg 1785), DROP-TABLE protection (Msg 3726), or `sys.foreign_keys` / `sys.foreign_key_columns`**[`docs/claude/foreign-keys.md`](docs/claude/foreign-keys.md).
152154
- **Touching `PERIOD FOR SYSTEM_TIME` / `GENERATED ALWAYS AS ROW START / END [HIDDEN]` / `WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = …))` DDL, `ALTER TABLE … SET (SYSTEM_VERSIONING = OFF)`, the auto-created history sibling, `FOR SYSTEM_TIME ALL / AS OF` query syntax, or `HeapTable.PeriodColumns` / `HeapTable.SystemVersioning` / `HeapColumn.GeneratedAs` / `HeapColumn.IsHidden`**[`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md).
153155
- **Adding a new top-level statement parser or changing the dispatch loop's statement-separator rules**[`docs/claude/grammar.md`](docs/claude/grammar.md) + [`docs/claude/control-flow.md`](docs/claude/control-flow.md).
154156

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SqlServerSimulator.EFCore;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// End-to-end test for EF Core's relationship modeling against the simulator.
8+
/// <c>HasOne</c>/<c>WithMany</c> emits a FOREIGN KEY constraint in the
9+
/// SqlServer-flavored CREATE TABLE shape; the simulator's parser + enforcer
10+
/// handle it on the round-tripped SaveChanges path. Tables are bootstrapped
11+
/// via raw CREATE TABLE statements (EF Core's <c>EnsureCreated</c> isn't
12+
/// modeled — see <see cref="EFCoreHiLo"/> for the same pattern).
13+
/// </summary>
14+
[TestClass]
15+
public sealed class EFCoreForeignKey
16+
{
17+
public TestContext TestContext { get; set; } = null!;
18+
19+
private sealed class Author
20+
{
21+
public int Id { get; set; }
22+
public string Name { get; set; } = "";
23+
public List<Book> Books { get; } = [];
24+
}
25+
26+
private sealed class Book
27+
{
28+
public int Id { get; set; }
29+
public string Title { get; set; } = "";
30+
public int AuthorId { get; set; }
31+
public Author Author { get; set; } = null!;
32+
}
33+
34+
private sealed class LibraryContext(Simulation simulation) : DbContext
35+
{
36+
public Simulation Simulation { get; } = simulation;
37+
38+
public DbSet<Author> Authors => Set<Author>();
39+
public DbSet<Book> Books => Set<Book>();
40+
41+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
42+
optionsBuilder.UseSqlServerSimulator(this.Simulation.CreateDbConnection());
43+
44+
protected override void OnModelCreating(ModelBuilder modelBuilder)
45+
{
46+
_ = modelBuilder.Entity<Author>(b =>
47+
{
48+
_ = b.Property(a => a.Name).HasColumnType("nvarchar(50)").IsRequired();
49+
});
50+
_ = modelBuilder.Entity<Book>(b =>
51+
{
52+
_ = b.Property(p => p.Title).HasColumnType("nvarchar(100)").IsRequired();
53+
_ = b.HasOne(p => p.Author)
54+
.WithMany(a => a.Books)
55+
.HasForeignKey(p => p.AuthorId)
56+
.OnDelete(DeleteBehavior.Cascade);
57+
});
58+
}
59+
}
60+
61+
private static Simulation CreateLibrarySimulation()
62+
{
63+
var simulation = new Simulation();
64+
_ = simulation.ExecuteNonQuery("""
65+
create table Authors (
66+
Id int not null identity(1,1) primary key,
67+
Name nvarchar(50) not null
68+
);
69+
create table Books (
70+
Id int not null identity(1,1) primary key,
71+
Title nvarchar(100) not null,
72+
AuthorId int not null,
73+
constraint fk_book_author foreign key (AuthorId) references Authors(Id) on delete cascade
74+
)
75+
""");
76+
return simulation;
77+
}
78+
79+
[TestMethod]
80+
public void SaveChanges_ParentAndChild_RoundTripsThroughFkColumn()
81+
{
82+
using var context = new LibraryContext(CreateLibrarySimulation());
83+
var author = new Author { Name = "Ada Lovelace" };
84+
author.Books.Add(new Book { Title = "Notes on the Analytical Engine" });
85+
author.Books.Add(new Book { Title = "Sketch of the Engine" });
86+
_ = context.Authors.Add(author);
87+
_ = context.SaveChanges();
88+
89+
Assert.AreEqual(1, context.Authors.AsNoTracking().Count());
90+
Assert.AreEqual(2, context.Books.AsNoTracking().Count());
91+
Assert.AreEqual(2, context.Books.AsNoTracking().Count(b => b.AuthorId == author.Id));
92+
}
93+
94+
[TestMethod]
95+
public void SaveChanges_BookWithoutAuthor_RaisesDbUpdateException()
96+
{
97+
// FK violation on the child INSERT surfaces as DbUpdateException
98+
// (the simulator's Msg 547 in the inner exception).
99+
using var context = new LibraryContext(CreateLibrarySimulation());
100+
_ = context.Books.Add(new Book { Title = "Orphan", AuthorId = 999 });
101+
var ex = Assert.Throws<DbUpdateException>(() => context.SaveChanges());
102+
Assert.IsNotNull(ex.InnerException);
103+
Assert.Contains("FOREIGN KEY constraint", ex.InnerException.Message);
104+
}
105+
106+
[TestMethod]
107+
public void DeleteAuthor_CascadesToBooks()
108+
{
109+
// ON DELETE CASCADE on the FK propagates the parent delete to child
110+
// rows. EF Core's client-side cascade fixup is bypassed by using
111+
// raw SQL DELETE on the connection — exercising the simulator's
112+
// server-side cascade engine.
113+
using var context = new LibraryContext(CreateLibrarySimulation());
114+
var author = new Author { Name = "Marie Curie" };
115+
author.Books.Add(new Book { Title = "Recherches sur les substances radioactives" });
116+
_ = context.Authors.Add(author);
117+
_ = context.SaveChanges();
118+
Assert.AreEqual(1, context.Books.AsNoTracking().Count());
119+
120+
using var connection = context.Database.GetDbConnection();
121+
if (connection.State != System.Data.ConnectionState.Open)
122+
connection.Open();
123+
using var cmd = connection.CreateCommand();
124+
cmd.CommandText = "delete from Authors";
125+
_ = cmd.ExecuteNonQuery();
126+
127+
Assert.AreEqual(0, context.Authors.AsNoTracking().Count());
128+
Assert.AreEqual(0, context.Books.AsNoTracking().Count());
129+
}
130+
}

0 commit comments

Comments
 (0)