Skip to content

Commit 8f65789

Browse files
committed
PRIMARY KEY / UNIQUE on PERSISTED computed columns — lifts the broad NotSupportedException at Simulation.Create.cs lines 1073 (ParseTableKeyConstraint pending-computed-name scan) and 1129 (ResolveKeyConstraints final validator). PERSISTED computed columns are key-eligible because they carry a real storage ordinal (HeapColumn.IsStored=true via IsPersisted) — the existing EnforceKeyConstraints + EnforceUniqueIndexes loops read them by storage-ordinal lookup like any other stored column, so no change to the enforcement path is needed. ParseTableKeyConstraint's pending-computed branch now sets found = i and breaks instead of throwing — the column's null slot in heapColumns gets filled during the computed-column materialization pass; ResolveKeyConstraints then sees the finished HeapColumn and applies the new gate. Inline form (c AS expr PERSISTED NOT NULL PRIMARY KEY) is supported by extending the computed-column branch in ParseOneColumnIntoLists to consume an optional trailing PRIMARY KEY / UNIQUE keyword after the PERSISTED-[NOT-NULL] suffix and add the constraint to pendingKeys with the column's index. Rejection paths match probe-confirmed real SQL Server 2025 behavior: PK on non-persisted at CREATE TABLE → new Msg 1711 factory ComputedColumnPkRequiresPersisted ("The computed column has to be persisted and not nullable."); PK on non-persisted at ALTER ADD → existing Msg 8111 via the non-persisted-implies-nullable shortcut (probe-confirmed real SQL Server uses the different code at ALTER vs CREATE — both observable through the simulator's existing nullable-check path without special-casing); PK on PERSISTED nullable computed at either CREATE or ALTER → existing Msg 8111 (computed columns default to nullable=true unless explicitly PERSISTED NOT NULL — divergence from real SQL Server which appears to auto-promote-to-NOT-NULL when used as PK, documented as deferred since EF Core always emits explicit nullability and so apps don't reach the path); UNIQUE on non-persisted at CREATE TABLE → NotSupportedException (real SQL Server allows; deferred because enforcement would need per-row expression evaluation in EnforceUniqueIndexes / EnforceKeyConstraints rather than storage-ordinal lookup); same NotSupportedException at ALTER ADD (new guard in Simulation.AlterTableConstraint.cs's ParseAddKeyConstraint covers UNIQUE that the existing Msg 8111 nullable check doesn't catch). Existing obsolete test KeyConstraintTests.PrimaryKey_OnComputedColumn_NotSupported rewritten + 14 new tests cover the full surface: inline + table-level + ALTER ADD shapes across PK / UNIQUE; CREATE-time + ALTER-time + INSERT-time enforcement; compound PK with regular+computed columns; Msg 1711 / Msg 8111 / Msg 2627 / Msg 1505 paths; NotSupportedException for both non-persisted UNIQUE shapes. CLAUDE.md "Not modeled" entry rewritten — the broad "PK/UNIQUE on computed column" bullet now scoped down to "UNIQUE on a non-persisted computed column", explaining the storage-ordinal-vs-expression-evaluation distinction and the orthogonal Msg 4936 (PERSISTED determinism gate) gap that remains unenforced.
1 parent 7ae5038 commit 8f65789

5 files changed

Lines changed: 181 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
166166
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `1` / `10` / `12` / `23` / `101` / `102` / `103` / `110` / `112` / `120` / `121` / `126` / `127` (datetime ⇄ string, both directions; ISO 8601 + regional formats) and `0` / `1` / `2` (money → string). See [`docs/claude/casting.md`](docs/claude/casting.md).
167167
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
168168
- MERGE source as a CTE-headed SELECT (`USING (WITH cte AS … SELECT …)`) — Selection.Parse's CTE entry doesn't reach the USING-clause grammar; wrap the CTE inside a regular SELECT instead. MERGE inside a CTE body, multi-statement MERGE WHEN-clause bodies, and `MERGE INTO <view>` (real SQL Server's updatable-view MERGE) are also deferred.
169-
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
169+
- `UNIQUE` on a *non-persisted* computed column (`NotSupportedException`) — `PRIMARY KEY` / `UNIQUE` on a `PERSISTED` computed column ships (inline + table-level + ALTER ADD CONSTRAINT shapes; PK on non-persisted raises Msg 1711 at CREATE TABLE / Msg 8111 at ALTER ADD per probe). Non-persisted UNIQUE would need per-row expression evaluation in `EnforceKeyConstraints` / `EnforceUniqueIndexes` rather than storage-ordinal lookup. The orthogonal Msg 4936 determinism gate for PERSISTED computed columns themselves (rejecting GETDATE / NEWID etc.) isn't enforced either; non-deterministic PERSISTED computed columns silently materialize.
170170
- Heap allocation tracking (flat page list, no IAM/PFS).
171171
- **Table-variable named constraints / foreign keys**`DECLARE @t TABLE (...)` shares its column-list parser with CREATE TABLE (see `docs/claude/table-variables.md`); column features (IDENTITY / UNIQUE / inline + table-level CHECK / computed columns / rowversion) all ship, alongside per-statement-atomic mutations and `OUTPUT … INTO <target>` for both `@t` and regular tables. Named constraints (`CONSTRAINT pk1 PRIMARY KEY`) and `FOREIGN KEY` raise Msg 102 (matches probe — real SQL Server's grammar disallows both shapes inside `DECLARE @t TABLE`). Multi-variable DECLARE with a table variable (`DECLARE @t1 TABLE (...), @t2 TABLE (...)`) and mixed scalar+table DECLARE also raise Msg 102/156. `SET IDENTITY_INSERT @t ON` likewise raises Msg 102 (probe-confirmed: there's no way to force a specific value into a table-variable identity column).
172172
- **Global temp tables (`##foo`)**`NotSupportedException` at parse. Local `#foo` works; the lifecycle for global temps (drops when creator session closes, visible across sessions) is the deferred scope.

SqlServerSimulator.Tests/KeyConstraintTests.cs

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,139 @@ insert t values (1, 100)
317317
Assert.Contains("UNIQUE KEY constraint 'uq_t'", uqEx.Message);
318318
}
319319

320+
// --- PK / UNIQUE on computed columns ---
321+
322+
[TestMethod]
323+
public void PrimaryKey_OnNonPersistedComputed_RaisesMsg1711()
324+
{
325+
// Probe-confirmed: real SQL Server raises Msg 1711 at CREATE TABLE when
326+
// PK targets a non-persisted computed column.
327+
var ex = new Simulation().AssertSqlError(
328+
"create table t (a int not null, c as a + 1, primary key (c))", 1711);
329+
Assert.Contains("computed column has to be persisted", ex.Message);
330+
Assert.Contains("'c'", ex.Message);
331+
}
332+
333+
[TestMethod]
334+
public void PrimaryKey_OnPersistedNullableComputed_RaisesMsg8111()
335+
{
336+
// PERSISTED without an explicit NOT NULL keeps the simulator's
337+
// computed-column nullability defaulted to true; the existing PK
338+
// nullable check catches it.
339+
_ = new Simulation().AssertSqlError(
340+
"create table t (a int null, c as a + 1 persisted, primary key (c))", 8111);
341+
}
342+
343+
[TestMethod]
344+
public void PrimaryKey_OnPersistedNotNullComputed_TableLevel_Succeeds()
345+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
346+
create table t (a int not null, c as a + 1 persisted not null, primary key (c));
347+
insert t(a) values (1), (2);
348+
select count(*) from t
349+
"""));
350+
351+
[TestMethod]
352+
public void PrimaryKey_OnPersistedNotNullComputed_Inline_Succeeds()
353+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
354+
create table t (a int not null, c as a + 1 persisted not null primary key);
355+
insert t(a) values (1), (2);
356+
select count(*) from t
357+
"""));
358+
359+
[TestMethod]
360+
public void PrimaryKey_OnPersistedComputed_EnforcesUniqueness()
361+
{
362+
var simulation = new Simulation();
363+
_ = simulation.ExecuteNonQuery("""
364+
create table t (a int not null, c as a + 1 persisted not null, primary key (c));
365+
insert t(a) values (1), (2)
366+
""");
367+
// a=1 → c=2 collides with existing c=2 from a=1
368+
var ex = simulation.AssertSqlError("insert t(a) values (1)", 2627);
369+
Assert.Contains("PRIMARY KEY", ex.Message);
370+
Assert.Contains("(2)", ex.Message);
371+
}
372+
373+
[TestMethod]
374+
public void PrimaryKey_OnPersistedComputed_Compound_Succeeds()
375+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
376+
create table t (a int not null, c as a + 1 persisted not null, x int not null,
377+
primary key (a, c));
378+
insert t(a, x) values (1, 100), (2, 200);
379+
select count(*) from t
380+
"""));
381+
382+
[TestMethod]
383+
public void Unique_OnPersistedComputed_TableLevel_Succeeds()
384+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
385+
create table t (a int not null, c as a * 10 persisted, unique (c));
386+
insert t(a) values (1), (2);
387+
select count(*) from t
388+
"""));
389+
390+
[TestMethod]
391+
public void Unique_OnPersistedComputed_EnforcesUniqueness()
392+
{
393+
var simulation = new Simulation();
394+
_ = simulation.ExecuteNonQuery("""
395+
create table t (a int not null, c as a * 10 persisted, unique (c));
396+
insert t(a) values (1), (2)
397+
""");
398+
var ex = simulation.AssertSqlError("insert t(a) values (1)", 2627);
399+
Assert.Contains("UNIQUE KEY", ex.Message);
400+
Assert.Contains("(10)", ex.Message);
401+
}
402+
320403
[TestMethod]
321-
public void PrimaryKey_OnComputedColumn_NotSupported()
404+
public void Unique_OnNonPersistedComputed_NotSupported()
322405
{
323-
// Real SQL Server allows PK/UNIQUE on a computed column; simulator's v1 doesn't model this.
324-
var ex = Assert.Throws<NotSupportedException>(() => new Simulation().ExecuteNonQuery(
325-
"create table t (a int not null, c as a + 1, primary key (c))"));
326-
Assert.Contains("computed column", ex.Message);
406+
// Real SQL Server allows this; the simulator defers UNIQUE-on-non-
407+
// persisted because enforcement would need per-row expression
408+
// evaluation rather than storage-ordinal lookup.
409+
_ = Assert.Throws<NotSupportedException>(() => new Simulation().ExecuteNonQuery(
410+
"create table t (a int not null, c as a * 10, unique (c))"));
327411
}
328412

413+
[TestMethod]
414+
public void AlterTable_AddPrimaryKey_OnPersistedComputed_Succeeds()
415+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
416+
create table t (a int not null, c as a + 1 persisted not null);
417+
insert t(a) values (1), (2);
418+
alter table t add constraint pk_t primary key (c);
419+
select count(*) from t
420+
"""));
421+
422+
[TestMethod]
423+
public void AlterTable_AddPrimaryKey_OnPersistedComputed_RejectsExistingDuplicate()
424+
=> _ = new Simulation().AssertSqlError("""
425+
create table t (a int not null, c as a + 1 persisted not null);
426+
insert t(a) values (1), (1);
427+
alter table t add constraint pk_t primary key (c)
428+
""", 1505);
429+
430+
[TestMethod]
431+
public void AlterTable_AddPrimaryKey_OnNonPersistedComputed_RaisesMsg8111()
432+
=> _ = new Simulation().AssertSqlError("""
433+
create table t (a int not null, c as a + 1);
434+
alter table t add constraint pk_t primary key (c)
435+
""", 8111);
436+
437+
[TestMethod]
438+
public void AlterTable_AddUnique_OnPersistedComputed_Succeeds()
439+
=> Assert.AreEqual(2, new Simulation().ExecuteScalar("""
440+
create table t (a int not null, c as a + 1 persisted);
441+
insert t(a) values (1), (2);
442+
alter table t add unique (c);
443+
select count(*) from t
444+
"""));
445+
446+
[TestMethod]
447+
public void AlterTable_AddUnique_OnNonPersistedComputed_NotSupported()
448+
=> _ = Assert.Throws<NotSupportedException>(() => new Simulation().ExecuteNonQuery("""
449+
create table t (a int not null, c as a + 1);
450+
alter table t add unique (c)
451+
"""));
452+
329453
[TestMethod]
330454
public void MergeInsert_DuplicateKey_RaisesMsg2627()
331455
{

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,15 @@ internal static SimulatedSqlException PrimaryKeyOnNullableColumn(string tableNam
396396
internal static SimulatedSqlException KeyColumnInvalidType(string columnName, string tableName) =>
397397
new($"Column '{columnName}' in table '{tableName}' is of a type that is invalid for use as a key column in an index.", 1919, 16, 1);
398398

399+
/// <summary>
400+
/// Mimics SQL Server error 1711: <c>PRIMARY KEY</c> targeted a computed
401+
/// column that isn't <c>PERSISTED</c>. Probe-confirmed at CREATE TABLE
402+
/// (the ALTER-ADD-PK path raises Msg 8111 instead, via the non-persisted-
403+
/// implies-nullable shortcut). Real SQL Server uses identical wording.
404+
/// </summary>
405+
internal static SimulatedSqlException ComputedColumnPkRequiresPersisted(string columnName, string tableName) =>
406+
new($"Cannot define PRIMARY KEY constraint on column '{columnName}' in table '{tableName}'. The computed column has to be persisted and not nullable.", 1711, 16, 1);
407+
399408
/// <summary>
400409
/// Mimics SQL Server error 8102: an UPDATE statement targeted an identity
401410
/// column. <c>SET IDENTITY_INSERT</c> only opens INSERT to identity

SqlServerSimulator/Simulation/Simulation.AlterTableConstraint.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,14 @@ private static bool ParseAddKeyConstraint(ParserContext context, MultiPartName t
261261
var col = table.Columns[fullOrdinals[i]];
262262
if (col.IsLob)
263263
throw SimulatedSqlException.KeyColumnInvalidType(col.Name, table.Name);
264+
// Non-persisted computed columns have no storage ordinal — UNIQUE
265+
// on one would need expression evaluation in the enforcement loop
266+
// (not modeled). PRIMARY KEY on a non-persisted computed already
267+
// raised Msg 8111 above (computed columns are nullable by default
268+
// and the existing nullable check catches it — probe-confirmed
269+
// at ALTER ADD, the wording differs from CREATE TABLE's Msg 1711).
270+
if (col.Computed is not null && !col.IsPersisted)
271+
throw new NotSupportedException("UNIQUE on a non-persisted computed column isn't modeled.");
264272
storageOrdinals[i] = table.StorageOrdinals[fullOrdinals[i]];
265273
}
266274

SqlServerSimulator/Simulation/Simulation.Create.cs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,9 +545,20 @@ internal static void ParseOneColumnIntoLists(
545545
context.MoveNextRequired();
546546
var computed = Expression.Parse(context);
547547
var (persisted, computedNullable) = ParseComputedSuffix(context);
548-
pendingComputed.Add((heapColumns.Count, columnName.Value, computed, persisted, computedNullable));
548+
var computedIndex = heapColumns.Count;
549+
pendingComputed.Add((computedIndex, columnName.Value, computed, persisted, computedNullable));
549550
heapColumns.Add(null);
550551
explicitNull.Add(false);
552+
// Inline PRIMARY KEY / UNIQUE after a computed column's
553+
// PERSISTED [NOT NULL] suffix — probe-confirmed: `c AS expr
554+
// PERSISTED NOT NULL PRIMARY KEY` is legal at CREATE TABLE.
555+
// ResolveKeyConstraints applies the persisted/nullability gate
556+
// after computed-column materialization.
557+
if (context.Token is ReservedKeyword { Keyword: Keyword.Primary or Keyword.Unique })
558+
{
559+
var inlineKind = ParseInlineKeyKindAndModifiers(context);
560+
pendingKeys.Add((inlineKind, null, [computedIndex]));
561+
}
551562
return;
552563
}
553564

@@ -1070,8 +1081,18 @@ private static void ParseTableLevelConstraint(
10701081
foreach (var pending in pendingComputed)
10711082
{
10721083
if (pending.Index == i && Collation.Default.Equals(pending.Name, keyColumn.Value))
1073-
throw new NotSupportedException("PRIMARY KEY/UNIQUE on a computed column.");
1084+
{
1085+
// Computed columns participate as key columns when
1086+
// PERSISTED — validated in ResolveKeyConstraints
1087+
// after computed-column materialization fills the
1088+
// null slot. Record the ordinal; the persistence
1089+
// gate fires later.
1090+
found = i;
1091+
break;
1092+
}
10741093
}
1094+
if (found >= 0)
1095+
break;
10751096
}
10761097
}
10771098
if (found < 0)
@@ -1097,11 +1118,12 @@ private static void ParseTableLevelConstraint(
10971118
/// by storage ordinal. Enforces SQL Server's compile-time rules: at most
10981119
/// one PRIMARY KEY per table (Msg 8110), no PK on a column whose declared
10991120
/// nullability is NULL (Msg 8111 — also fires for table-level PK on a
1100-
/// column declared NULL), no key column of LOB type (Msg 1919). Generates
1101-
/// a SQL-Server-shaped auto name for any unnamed constraint
1121+
/// column declared NULL), no key column of LOB type (Msg 1919),
1122+
/// computed-column participation requires <see cref="HeapColumn.IsPersisted"/>
1123+
/// (PK on a non-persisted computed → Msg 1711; UNIQUE on a non-persisted
1124+
/// computed → <see cref="NotSupportedException"/>, deferred). Generates a
1125+
/// SQL-Server-shaped auto name for any unnamed constraint
11021126
/// (<c>PK__&lt;table&gt;__&lt;hex&gt;</c> / <c>UQ__&lt;table&gt;__&lt;hex&gt;</c>).
1103-
/// Computed columns are not yet supported as key participants — those
1104-
/// raise <see cref="NotSupportedException"/>.
11051127
/// </summary>
11061128
internal static KeyConstraint[] ResolveKeyConstraints(
11071129
string tableName,
@@ -1125,8 +1147,12 @@ internal static KeyConstraint[] ResolveKeyConstraints(
11251147
{
11261148
var fullOrdinal = pending.FullOrdinals[i];
11271149
var column = heapColumns[fullOrdinal];
1128-
if (column.Computed is not null)
1129-
throw new NotSupportedException("PRIMARY KEY/UNIQUE on a computed column.");
1150+
if (column.Computed is not null && !column.IsPersisted)
1151+
{
1152+
if (pending.Kind == KeyConstraintKind.PrimaryKey)
1153+
throw SimulatedSqlException.ComputedColumnPkRequiresPersisted(column.Name, tableName);
1154+
throw new NotSupportedException("UNIQUE on a non-persisted computed column isn't modeled.");
1155+
}
11301156
if (column.IsLob)
11311157
throw SimulatedSqlException.KeyColumnInvalidType(column.Name, tableName);
11321158
if (pending.Kind == KeyConstraintKind.PrimaryKey && column.Nullable)

0 commit comments

Comments
 (0)