Skip to content

Commit c7f5ec3

Browse files
committed
CREATE [UNIQUE] [CLUSTERED | NONCLUSTERED] INDEX … ON table (col [ASC | DESC] [, …]) [INCLUDE (cols)] [WHERE filter] [WITH (options)] + DROP INDEX [IF EXISTS] name ON table [, …] + sys.indexes / sys.index_columns catalog views — closes the largest remaining EF Migrations DDL gap. New Storage/Index.cs (Name / ObjectId / IsUnique / IsClustered / IndexKeyColumn[] / IncludedColumns / Filter) and HeapTable.Indexes list; Simulation.CreateIndex.cs partial routes from the CREATE dispatch (Unique / Clustered / NonClustered / Index keywords) through modifier parsing, column resolution (Msg 1911 missing column), duplicate-name check against both Indexes + KeyConstraints (Msg 1913 with dbo.t qualification), and existing-data validation (Msg 1505 reusing the ALTER-bundle helper, filter-aware so excluded rows don't trigger). WITH (option = value) parsed parens-balanced and discarded (FILLFACTOR / IGNORE_DUP_KEY / ONLINE / etc all accepted, none honored). DROP INDEX routes ahead of the comma-list path in TryParseDrop (different grammar: name ON table), surfaces Msg 3701 St 6 / St 7 for missing-table / missing-index, Msg 3723 with PRIMARY KEY or UNIQUE constraint-kind word when targeting a PK/UQ-backing index, and supports IF EXISTS suppression for the does-not-exist paths only (probe-confirmed: 3723 fires regardless of IF EXISTS). UNIQUE enforcement extends both INSERT (EnforceUniqueIndexes in Simulation.Coerce.cs, called from Simulation.Insert.cs) and UPDATE / MERGE (EnforceUniqueIndexesForUpdate in Simulation.Update.cs, called from Simulation.Update.cs + Simulation.Merge.cs), raising Msg 2601 with the standard dbo.t qualification and key-tuple rendering. Filter-aware on both paths: an index with a WHERE filter only checks rows where the filter evaluates true on both the new row and the existing row being compared — false / UNKNOWN both skip, mirroring SQL Server's filtered-unique-index semantic. sys.indexes (24-column probe-confirmed shape) synthesizes one row per (table, index): PK at index_id=1 type_desc=CLUSTERED is_primary_key=1; tables without a PK emit index_id=0 type_desc=HEAP name=NULL; UQ constraints and user indexes share index_id 2+ in ObjectId order (matches SQL Server's declaration-order behavior); is_unique_constraint distinguishes UQ from CREATE UNIQUE INDEX. sys.index_columns (10-column probe-confirmed shape) emits per-column rows with key_ordinal 1..N for KEY columns and 0 for INCLUDE columns, is_descending_key from the per-column DESC flag, and column_id mapped back from storage ordinal via sys.columns-compatible full-column ordinal. Six new error factories: IndexAlreadyExists (Msg 1913), CannotFindObjectForCreateIndex (Msg 1088), CannotDropIndexDoesNotExist (Msg 3701 with state parameter), ExplicitDropIndexNotAllowed (Msg 3723), ViolationOfUniqueIndex (Msg 2601). 37 new CreateIndexTests cover grammar (UNIQUE / CLUSTERED / NONCLUSTERED / multi-column ASC / DESC / INCLUDE / WHERE filter / WITH options), error paths (1088 / 1505 / 1911 / 1913 / 3701 St 6+7 / 3723 on PK + UQ), filter-aware uniqueness (allows duplicates outside filter, rejects inside, UPDATE doesn't false-trigger across filter-excluded rows), Msg 2601 at INSERT + UPDATE, sys.indexes synthesis (HEAP for no-PK / CLUSTERED for PK / index_id assignment / is_unique_constraint vs is_unique distinction), and sys.index_columns shape. CLAUDE.md gains a trigger-phrase entry; new docs/claude/indexes.md documents the storage shape, enforcement loops, filter-aware semantic, catalog projection, and the four documented fidelity gaps (filter_definition always NULL, CLUSTERED keyword decorative, multiple-CLUSTERED silently accepted, WITH options ignored).
1 parent 56175e6 commit c7f5ec3

16 files changed

Lines changed: 1593 additions & 2 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
153153
- **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).
154154
- **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).
155155
- **Touching `ALTER TABLE … ADD CONSTRAINT` (PK / UQ / FK / CHECK / DEFAULT), `ALTER TABLE … DROP CONSTRAINT [IF EXISTS]`, `ALTER TABLE … (CHECK | NOCHECK) CONSTRAINT (ALL | name [,…])` trust toggling, `WITH CHECK` / `WITH NOCHECK`, the existing-data-validation scan, the constraint-name uniqueness check, the atomic multi-drop / multi-toggle pipeline, the `IsDisabled` / `IsNotTrusted` enforcement gates, or `sys.check_constraints` / `sys.key_constraints` / `sys.default_constraints`**[`docs/claude/alter-table.md`](docs/claude/alter-table.md).
156+
- **Touching `CREATE [UNIQUE] [CLUSTERED | NONCLUSTERED] INDEX` (with `INCLUDE` / `WHERE` filter / `WITH (options)` clauses), `DROP INDEX [IF EXISTS] name ON table [, …]`, the `HeapTable.Indexes` list, UNIQUE-index enforcement (filter-aware Msg 2601 via `EnforceUniqueIndexes` / `EnforceUniqueIndexesForUpdate`), the PK-or-UQ-name DROP rejection (Msg 3723), or `sys.indexes` / `sys.index_columns`**[`docs/claude/indexes.md`](docs/claude/indexes.md).
156157
- **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).
157158

158159
## Not modeled

SqlServerSimulator.Tests/CreateIndexTests.cs

Lines changed: 389 additions & 0 deletions
Large diffs are not rendered by default.

SqlServerSimulator/BuiltInResources.cs

Lines changed: 328 additions & 0 deletions
Large diffs are not rendered by default.

SqlServerSimulator/Errors/SimulatedSqlException.ConstraintErrors.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,28 @@ internal static SimulatedSqlException CheckConstraintViolation(string constraint
8484
/// PRIMARY KEY or UNIQUE-constraint key tuple already existed in the
8585
/// table. SQL Server uses Msg 2627 for both PK and UNIQUE *constraint*
8686
/// violations (Msg 2601 is for unique-index violations from
87-
/// <c>CREATE UNIQUE INDEX</c>, which the simulator doesn't model).
87+
/// <c>CREATE UNIQUE INDEX</c>, raised via
88+
/// <see cref="ViolationOfUniqueIndex"/>).
8889
/// <paramref name="kindWord"/> selects between <c>"PRIMARY KEY"</c> and
8990
/// <c>"UNIQUE KEY"</c>; <paramref name="formattedKeyValues"/> is the
9091
/// rendered tuple text without enclosing parens (e.g. <c>"1, &lt;NULL&gt;"</c>).
9192
/// </summary>
9293
internal static SimulatedSqlException ViolationOfKeyConstraint(string kindWord, string constraintName, string tableName, string formattedKeyValues) =>
9394
new($"Violation of {kindWord} constraint '{constraintName}'. Cannot insert duplicate key in object 'dbo.{tableName}'. The duplicate key value is ({formattedKeyValues}).", 2627, 14, 1);
9495

96+
/// <summary>
97+
/// Mimics SQL Server error 2601: an INSERT or UPDATE produced a row
98+
/// whose key tuple already existed in a <c>CREATE UNIQUE INDEX</c>-
99+
/// declared index. Distinct from Msg 2627 (unique <em>constraint</em>
100+
/// violation) — same scenario semantically, different error number
101+
/// per the surface. Probe-confirmed wording (the trailing
102+
/// <c>"The statement has been terminated."</c> phrase is emitted as a
103+
/// separate informational line by real SQL Server; the simulator
104+
/// folds it into the primary error message).
105+
/// </summary>
106+
internal static SimulatedSqlException ViolationOfUniqueIndex(string indexName, string qualifiedTableName, string formattedKeyValues) =>
107+
new($"Cannot insert duplicate key row in object '{qualifiedTableName}' with unique index '{indexName}'. The duplicate key value is ({formattedKeyValues}).", 2601, 14, 1);
108+
95109
/// <summary>
96110
/// Mimics SQL Server error 547 on the child side: an <c>INSERT</c> /
97111
/// <c>UPDATE</c> / <c>MERGE</c> placed a value in a FOREIGN KEY column

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,4 +741,41 @@ internal static SimulatedSqlException ConstraintReferencedByForeignKey(string co
741741
/// </summary>
742742
internal static SimulatedSqlException ConstraintDoesNotExist(string name) =>
743743
new($"Constraint '{name}' does not exist.", 4917, 16, 0);
744+
745+
/// <summary>
746+
/// Mimics SQL Server error 1913: <c>CREATE INDEX</c> with a name that
747+
/// already exists on the target table. Probe-confirmed verbatim
748+
/// against SQL Server 2025 — message names the index and the
749+
/// qualified table.
750+
/// </summary>
751+
internal static SimulatedSqlException IndexAlreadyExists(string indexName, string qualifiedTableName) =>
752+
new($"The operation failed because an index or statistics with name '{indexName}' already exists on table '{qualifiedTableName}'.", 1913, 16, 1);
753+
754+
/// <summary>
755+
/// Mimics SQL Server error 1088: <c>CREATE INDEX</c> (or any
756+
/// catalog-scoped reference) named a target object that doesn't exist.
757+
/// Distinct from Msg 208 (which surfaces from DML) — Msg 1088 is the
758+
/// CREATE-INDEX / sp_help-shaped diagnostic. State 12 probe-confirmed.
759+
/// </summary>
760+
internal static SimulatedSqlException CannotFindObjectForCreateIndex(string qualifiedName) =>
761+
new($"Cannot find the object \"{qualifiedName}\" because it does not exist or you do not have permissions.", 1088, 16, 12);
762+
763+
/// <summary>
764+
/// Mimics SQL Server error 3701 with the <c>index</c> wording variant:
765+
/// <c>DROP INDEX name ON table</c> targeted an index that doesn't
766+
/// exist. State 6 when the parent table itself is missing; State 7
767+
/// when the table exists but has no such index. Probe-confirmed.
768+
/// </summary>
769+
internal static SimulatedSqlException CannotDropIndexDoesNotExist(string qualifiedTableName, string indexName, byte state) =>
770+
new($"Cannot drop the index '{qualifiedTableName}.{indexName}', because it does not exist or you do not have permission.", 3701, 11, state);
771+
772+
/// <summary>
773+
/// Mimics SQL Server error 3723: <c>DROP INDEX</c> targeted a system
774+
/// index that backs a PRIMARY KEY or UNIQUE constraint. Real SQL
775+
/// Server forbids dropping the index directly; the caller must
776+
/// <c>ALTER TABLE … DROP CONSTRAINT</c> instead. Probe-confirmed
777+
/// wording.
778+
/// </summary>
779+
internal static SimulatedSqlException ExplicitDropIndexNotAllowed(string qualifiedTableName, string indexName, string constraintKindWord) =>
780+
new($"An explicit DROP INDEX is not allowed on index '{qualifiedTableName}.{indexName}'. It is being used for {constraintKindWord} constraint enforcement.", 3723, 16, 4);
744781
}

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ enum ContextualKeyword
4444
Grouping,
4545
Hidden,
4646
History_Table,
47+
Include,
4748
Increment,
4849
Input,
4950
Instead,

SqlServerSimulator/Simulation/Simulation.Coerce.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,82 @@ private static void EnforceKeyConstraints(HeapTable destinationTable, SqlValue[]
260260
}
261261
}
262262

263+
/// <summary>
264+
/// Walks <see cref="HeapTable.Indexes"/> for every UNIQUE entry and
265+
/// raises Msg 2601 on the first key-tuple collision against existing
266+
/// rows. When an index has a <c>Index.Filter</c>, only rows for
267+
/// which the filter evaluates true on both sides participate in the
268+
/// uniqueness check (filtered-unique-index semantic). Mirrors
269+
/// <see cref="EnforceKeyConstraints"/>'s linear-scan shape; called
270+
/// alongside it after a successful row build.
271+
/// </summary>
272+
private static void EnforceUniqueIndexes(HeapTable destinationTable, SqlValue[] rowValues, SqlValue[] storedRowValues, BatchContext batch)
273+
{
274+
if (destinationTable.Indexes.Count == 0)
275+
return;
276+
277+
var hasUnique = false;
278+
foreach (var ix in destinationTable.Indexes)
279+
{
280+
if (ix.IsUnique)
281+
{
282+
hasUnique = true;
283+
break;
284+
}
285+
}
286+
if (!hasUnique)
287+
return;
288+
289+
var storedColumns = destinationTable.StoredColumns;
290+
var lobStore = destinationTable.Heap;
291+
SqlValue[]? existingRowValues = null;
292+
var qualifiedTableName = $"{Database.DefaultSchemaName}.{destinationTable.Name}";
293+
294+
foreach (var index in destinationTable.Indexes)
295+
{
296+
if (!index.IsUnique)
297+
continue;
298+
if (index.Filter is not null && Simulation.EvaluateIndexFilter(index.Filter, destinationTable, rowValues, batch) != true)
299+
continue;
300+
301+
foreach (var rowBytes in destinationTable.Heap.EnumerateRows())
302+
{
303+
if (index.Filter is { } filter)
304+
{
305+
existingRowValues ??= new SqlValue[destinationTable.Columns.Length];
306+
for (var c = 0; c < destinationTable.Columns.Length; c++)
307+
{
308+
var storageOrd = destinationTable.StorageOrdinals[c];
309+
existingRowValues[c] = storageOrd >= 0
310+
? RowDecoder.DecodeColumn(storedColumns, rowBytes, storageOrd, lobStore)
311+
: SqlValue.Null(destinationTable.Columns[c].Type);
312+
}
313+
if (Simulation.EvaluateIndexFilter(filter, destinationTable, existingRowValues, batch) != true)
314+
continue;
315+
}
316+
317+
var allEqual = true;
318+
for (var i = 0; i < index.KeyColumns.Length; i++)
319+
{
320+
var ord = index.KeyColumns[i].StorageOrdinal;
321+
var existing = RowDecoder.DecodeColumn(storedColumns, rowBytes, ord, lobStore);
322+
if (!existing.Equals(storedRowValues[ord]))
323+
{
324+
allEqual = false;
325+
break;
326+
}
327+
}
328+
if (allEqual)
329+
{
330+
var key = new SqlValue[index.KeyColumns.Length];
331+
for (var i = 0; i < index.KeyColumns.Length; i++)
332+
key[i] = storedRowValues[index.KeyColumns[i].StorageOrdinal];
333+
throw SimulatedSqlException.ViolationOfUniqueIndex(index.Name, qualifiedTableName, Simulation.FormatIndexKeyValues(key));
334+
}
335+
}
336+
}
337+
}
338+
263339
/// <summary>
264340
/// Renders a key-tuple slot the way SQL Server's Msg 2627 does: NULL as
265341
/// <c>&lt;NULL&gt;</c>, strings raw (no enclosing quotes), numerics in

SqlServerSimulator/Simulation/Simulation.Create.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ private bool TryParseCreate(ParserContext context)
2727
return Simulation.TryParseCreateProcedure(context, isAlter: false, createOrAlter: false);
2828
case ReservedKeyword { Keyword: Keyword.Trigger }:
2929
return Simulation.TryParseCreateTrigger(context, isAlter: false, createOrAlter: false);
30+
case ReservedKeyword { Keyword: Keyword.Unique or Keyword.Clustered or Keyword.NonClustered or Keyword.Index }:
31+
return Simulation.TryParseCreateIndex(context);
3032
case UnquotedString { ContextualKeyword: ContextualKeyword.Type }:
3133
return TryParseCreateType(context);
3234
case UnquotedString { ContextualKeyword: ContextualKeyword.Sequence }:

0 commit comments

Comments
 (0)