Skip to content

Commit 9fded38

Browse files
committed
Per-table INDEX-hint existence validation — Msg 307 (unknown INDEX(N) id) + Msg 308 (unknown INDEX(name) / INDEX = name) ship verbatim. Both fire only on FROM-source / JOIN-RHS heap-table positions — DML targets already short-circuit on Msg 1069 first (ValidateDmlTargetHints runs before per-index validation, matching probe: UPDATE t WITH (INDEX(name)) raises 1069 not 308 on real SQL Server 2025). TableHintInfo gains a nullable List<IndexHintArgument> IndexArguments field and a companion IndexHintArgument readonly struct (Id? xor Name?); the INDEX-name case in ConsumeOneTableHint now dispatches to a dedicated ConsumeIndexHintArguments walker rather than SkipBalancedParens. The walker accepts both INDEX(arg [, …]) and INDEX = arg forms, captures each numeric-literal arg as IndexHintArgument.ForId(value.AsInt32) and each identifier (Name-token) arg as IndexHintArgument.ForName(source.ToString()), and surfaces negative-integer / other-shape inputs as generic Msg 102 (probe-confirmed: real SQL Server rejects INDEX(-1) the same way). FORCESEEK / FORCESCAN keep the existing parse-and-discard posture via the SkipBalancedParens fall-through. New ValidateIndexHintArguments(TableHintInfo, HeapTable, string qualifiedTableName) walks the captured args and raises on the first failure: integer form valid if id == 0 (the "heap scan" reference, accepted even on clustered tables — probe-confirmed INDEX(0) works on every shape) or 1 <= id <= KeyConstraints.Count + Indexes.Count (matches sys.indexes row-count for the table excluding the synthetic HEAP row); name form valid if matches any KeyConstraints[].Name (PRIMARY KEY / UNIQUE) or Indexes[].Name case-insensitively via Collation.Default.Equals. Wired at the heap-table FROM-source path in Selection.cs:1052 (immediately after ParseOptionalTableHints returns); the call site builds the qualifier as $"{objectName.ImmediateQualifier ?? Database.DefaultSchemaName}.{heapTable.Name}" so 2-part au.t references surface as 'au.t' in the message and 1-part t surfaces as 'dbo.t'. New error factories IndexHintIdNotFound(int, string) and IndexHintNameNotFound(string, string) in SimulatedSqlException.ConcurrencyErrors.cs carry the probe-confirmed verbatim wording (single-quote convention, (specified in the FROM clause) suffix hard-coded even on JOIN-RHS per real-server behavior). 13 new QueryHintTests cover: known/unknown named-arg pairs, PRIMARY KEY constraint name, UNIQUE constraint name, case-insensitive name match, =-form known/unknown name, out-of-range int on PK-table (Msg 307), INDEX(1) on heap-only table (Msg 307), INDEX(0) always-valid, multi-arg INDEX(bad, good) raising 308 on first failing arg, schema-qualified table name embedded in error, negative-int rejected as Msg 102 at parse. The previously-passing Select_IndexHint_NamedArg_ReturnsRows test (parse-and-ignore stance) becomes Select_IndexHint_UnknownNamedArg_RaisesMsg308. CLAUDE.md "Query-hint gaps" section rewritten: the "hint-conflict and DML-target-specific rejections aren't enforced" claim removed (Msg 1047 / 1065 / 1069 were already shipped — doc was stale); replaced with explicit "Hint-conflict + DML-target rejections ship verbatim" and "Per-table index-hint validation ships" paragraphs documenting Msg 307 / 308 with their validation rules. Remaining hint-surface gaps now narrow to the FROM-source (unknown) Msg 102-vs-207/321 divergence and the FORCESEEK(name(cols)) nested-form name validation. docs/claude/query-hints.md "Not enforced" section split into "Enforced rejections" (with detailed Msg 307 / 308 / 1047 / 1065 / 1069 wording + rule docs) and a shrunken "Not enforced" (FORCESEEK nested-form name validation + Msg 8622 plan rejection + INDEX = (value-list) equals-form); probe-artifacts list at the doc tail updated with the new Msg 307 / 308 / INDEX(0) / INDEX(-1) findings.
1 parent e2b1ca1 commit 9fded38

6 files changed

Lines changed: 356 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
181181
- **Public `InfoMessage` event** — `SimulatedDbConnection.InfoMessage` ships as an `internal` event (with `internal SimulatedInfoMessageEventArgs` carrying `Message`, `LineNumber`, `Source`) and a `BatchContext`-side buffer that coalesces multiple `PRINT`s per command into one firing (joined with `\n`, line number = first contributing `PRINT`'s line). The internal surface is exercised through `SqlServerSimulator.Tests.Internal/PrintInfoMessageTests.cs`. Going *public* — exposing the event on the `SimulatedDbConnection` consumer surface for application use — is deferred pending the public-API shape decision (mirror SqlClient's `SqlInfoMessageEventArgs` exactly? trim to a minimal shape? add a `DbConnection`-compatible extension method instead of a typed event?). Subquery-in-operand still silently evaluates instead of raising Msg 1046 ("Subqueries are not allowed in this context"); non-string-value formatting routes through `SqlValue.CoerceTo(varchar(8000))` rather than SQL Server's PRINT-specific style 0 conventions (datetime → ISO instead of `"May 14 2026 12:00AM"`; money → `F4` instead of 2-decimal). The 8000-byte ANSI / 4000-character Unicode PRINT truncation isn't enforced — long strings pass through verbatim.
182182
- **`ALTER TABLE` grammar beyond SET / ADD / DROP / ALTER COLUMN / CHECK / NOCHECK CONSTRAINT** — `SET (SYSTEM_VERSIONING = OFF)` (see [`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md)), `[WITH CHECK | WITH NOCHECK] ADD [CONSTRAINT name] (PRIMARY KEY | UNIQUE | FOREIGN KEY | CHECK | DEFAULT) …`, `DROP CONSTRAINT [IF EXISTS] name [, …]`, `[WITH CHECK | WITH NOCHECK] (CHECK | NOCHECK) CONSTRAINT (ALL | name [, …])` (trust toggling for bulk imports — sets / clears `is_disabled` + `is_not_trusted`), `ADD [COLUMN] col TYPE [, …]` (multi-column add — inline DEFAULT / IDENTITY / CHECK / FK / computed all supported; Msg 4901 on NOT NULL without DEFAULT on non-empty table; eager row rewrite with NULL backfill for nullable adds and constant-snapshot DEFAULT for NOT NULL), `DROP COLUMN [IF EXISTS] col [, …]` (Msg 4924 missing column, Msg 5074 dependency rejection with multi-blocker enumeration across PK/UQ/FK/CHECK/DEFAULT/index, atomic multi-drop), and `ALTER COLUMN col TYPE[(prec[,scale])] [COLLATE coll] [NULL|NOT NULL]` (single-column shape — type widening / narrowing routed through `SqlValue.CoerceTo` so Msg 220 / 245 / 241 / 8115 / 2628 surface verbatim; nullability flip raises Msg 515 on existing NULL; Msg 4928 for COMPUTED / rowversion; Msg 5074 multi-blocker enumeration across PK / UQ / FK both directions / computed-column dependencies / type-changing index references — length widening within the same `SqlType` family allowed under an index; COLLATE clause parse-accepted then ignored; identity / DEFAULT / inline CHECK preserve through the column instance swap) all ship (see [`docs/claude/alter-table.md`](docs/claude/alter-table.md)). DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, the SET-versioning-on direction, the `ALTER COLUMN col ADD/DROP {PERSISTED|MASKED|ROWGUIDCOL|SPARSE}` sub-clause forms, ALTER COLUMN on an identity column targeting a non-integer type, and every other shape raise `NotSupportedException`. Multi-constraint ADD (`ADD CONSTRAINT pk1 PK (a), CONSTRAINT fk1 FK …`) also raises.
183183
- `hierarchyid`, `geography`, `geometry`.
184-
- **Query-hint gaps** — table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / INSERT / UPDATE / DELETE / MERGE targets) and statement-level `OPTION (…)` hints ship via parse-and-discard (see [`docs/claude/query-hints.md`](docs/claude/query-hints.md)). The legacy bare-paren `(hint)` form is FROM / JOIN-RHS only — INSERT treats `(` as the column-list opener (probe-confirmed Msg 207 on the would-be hint name), and UPDATE / DELETE / MERGE all raise Msg 102 on the bare-paren form (controlled via `allowLegacyParenForm: false` at those call sites). **MERGE target uses hint-then-alias placement** — opposite of FROM / UPDATE / DELETE — and alias-then-hint raises Msg 156 there (probe-confirmed). **INSERT / MERGE on a `@t` target rejects WITH outright** (Msg 156 on real SQL Server; the simulator falls through to its generic Msg 102 since hint parsing is skipped for `@t`). Closed accept-list raises `Msg 321` for unknown table hints (verbatim probe wording), `Msg 102` for unknown OPTION hints (probe-confirmed: SQL Server has no dedicated unknown-OPTION-hint code). `MAXRECURSION N` retains its runtime effect on in-scope CTE bindings; every other recognized hint is a pure no-op. Remaining gaps: **hint-conflict and DML-target-specific rejections** (`Msg 1047` for `NOLOCK + XLOCK`, `Msg 1065` for `NOLOCK` / `READUNCOMMITTED` on any DML target, `Msg 1069` for `INDEX(…)` on any DML target, `Msg 308` for unknown `INDEX(name)`) aren't enforced — the simulator has no lock state or index dispatch to conflict over. **FROM-source `(unknown)` without alias** diverges minorly: real SQL Server raises Msg 207 (parses the paren as a TVF-arg-list attempt) while the simulator's peek-and-restore falls through to Msg 102; with-alias FROM/JOIN-RHS `alias (unknown)` also raises Msg 102 instead of real SQL Server's Msg 321 (commit-on-paren is wired only on MERGE bare-table source, not on FROM-source). (The shape `FROM t NOLOCK` without parens is not a deprecated hint form — `nolock` / `readpast` / etc. aren't reserved keywords, so it parses as the bare-alias form `FROM t <alias>` on both the simulator and real SQL Server; identical behavior, no gap.)
184+
- **Query-hint gaps** — table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / INSERT / UPDATE / DELETE / MERGE targets) and statement-level `OPTION (…)` hints ship via parse-and-discard (see [`docs/claude/query-hints.md`](docs/claude/query-hints.md)). The legacy bare-paren `(hint)` form is FROM / JOIN-RHS only — INSERT treats `(` as the column-list opener (probe-confirmed Msg 207 on the would-be hint name), and UPDATE / DELETE / MERGE all raise Msg 102 on the bare-paren form (controlled via `allowLegacyParenForm: false` at those call sites). **MERGE target uses hint-then-alias placement** — opposite of FROM / UPDATE / DELETE — and alias-then-hint raises Msg 156 there (probe-confirmed). **INSERT / MERGE on a `@t` target rejects WITH outright** (Msg 156 on real SQL Server; the simulator falls through to its generic Msg 102 since hint parsing is skipped for `@t`). Closed accept-list raises `Msg 321` for unknown table hints (verbatim probe wording), `Msg 102` for unknown OPTION hints (probe-confirmed: SQL Server has no dedicated unknown-OPTION-hint code). `MAXRECURSION N` retains its runtime effect on in-scope CTE bindings; every other recognized hint is a pure no-op. **Hint-conflict + DML-target rejections ship verbatim**: `Msg 1047` ("Conflicting locking hints specified.") for `NOLOCK` paired with any of `XLOCK / UPDLOCK / HOLDLOCK / SERIALIZABLE / REPEATABLEREAD / TABLOCKX`; `Msg 1065` for `NOLOCK` / `READUNCOMMITTED` on any DML target (INSERT / UPDATE / DELETE / MERGE); `Msg 1069` for `INDEX(…) / FORCESEEK / FORCESCAN` on any DML target. **Per-table index-hint validation ships**: `Msg 307` for an out-of-range `INDEX(N)` id (`N == 0` always valid; `N >= 1` valid iff `N <= KeyConstraints.Count + Indexes.Count`); `Msg 308` for an unknown `INDEX(name)` / `INDEX = name` (matched case-insensitively against PRIMARY KEY / UNIQUE constraint names and `HeapTable.Indexes`). Both fire only on FROM-source / JOIN-RHS positions — DML targets short-circuit on `Msg 1069` first per probe (`UPDATE t WITH (INDEX(name))` raises 1069 not 308 on real SQL Server). Remaining gaps: **FROM-source `(unknown)` without alias** diverges minorly — real SQL Server raises Msg 207 (parses the paren as a TVF-arg-list attempt) while the simulator's peek-and-restore falls through to Msg 102; with-alias FROM/JOIN-RHS `alias (unknown)` also raises Msg 102 instead of real SQL Server's Msg 321 (commit-on-paren is wired only on MERGE bare-table source, not on FROM-source). **`FORCESEEK(name(cols))` nested-form index-name validation** isn't run — the simulator skip-parses the nested syntax via `SkipBalancedParens` rather than capturing the leading name, so a `FORCESEEK(bad_name(c))` parses silently where real SQL Server raises Msg 308 (deferred since the nested-paren shape requires its own grammar path; the common bare `FORCESEEK` ships unaffected). (The shape `FROM t NOLOCK` without parens is not a deprecated hint form — `nolock` / `readpast` / etc. aren't reserved keywords, so it parses as the bare-alias form `FROM t <alias>` on both the simulator and real SQL Server; identical behavior, no gap.)
185185

186186
## Quirks (modeled, not byte-identical to SQL Server)
187187

SqlServerSimulator.Tests/QueryHintTests.cs

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,125 @@ public void Select_IndexHint_NumericArg_ReturnsRows()
117117
select count(*) from t with (index(0))
118118
"""));
119119

120-
/// <summary>
121-
/// Simulator doesn't model indexes, so the named-index reference parses
122-
/// and is discarded. Real SQL Server raises Msg 308 on a wrong name;
123-
/// the parse-and-ignore stance is the consistent posture for hints.
124-
/// </summary>
125120
[TestMethod]
126-
public void Select_IndexHint_NamedArg_ReturnsRows()
121+
public void Select_IndexHint_KnownNamedArg_ReturnsRows()
127122
=> AreEqual(3, new Simulation().ExecuteScalar("""
123+
create table t (id int primary key, v int);
124+
create index ix_v on t(v);
125+
insert t values (1, 10), (2, 20), (3, 30);
126+
select count(*) from t with (index(ix_v))
127+
"""));
128+
129+
[TestMethod]
130+
public void Select_IndexHint_UnknownNamedArg_RaisesMsg308()
131+
=> new Simulation().AssertSqlError("""
128132
create table t (id int primary key);
133+
insert t values (1);
134+
select * from t with (index(IX_does_not_exist))
135+
""", 308, "Index 'IX_does_not_exist' on table 'dbo.t' (specified in the FROM clause) does not exist.");
136+
137+
[TestMethod]
138+
public void Select_IndexHint_PkConstraintName_ReturnsRows()
139+
=> AreEqual(3, new Simulation().ExecuteScalar("""
140+
create table t (id int constraint pk_t primary key);
129141
insert t values (1), (2), (3);
130-
select count(*) from t with (index(IX_does_not_exist))
142+
select count(*) from t with (index(pk_t))
143+
"""));
144+
145+
[TestMethod]
146+
public void Select_IndexHint_UqConstraintName_ReturnsRows()
147+
=> AreEqual(3, new Simulation().ExecuteScalar("""
148+
create table t (id int primary key, u int constraint uq_u unique);
149+
insert t values (1, 10), (2, 20), (3, 30);
150+
select count(*) from t with (index(uq_u))
131151
"""));
132152

153+
[TestMethod]
154+
public void Select_IndexHint_NamedArg_CaseInsensitive_ReturnsRows()
155+
=> AreEqual(3, new Simulation().ExecuteScalar("""
156+
create table t (id int primary key, v int);
157+
create index ix_v on t(v);
158+
insert t values (1, 10), (2, 20), (3, 30);
159+
select count(*) from t with (index(IX_V))
160+
"""));
161+
162+
[TestMethod]
163+
public void Select_IndexHint_EqForm_KnownName_ReturnsRows()
164+
=> AreEqual(3, new Simulation().ExecuteScalar("""
165+
create table t (id int constraint pk_t primary key);
166+
insert t values (1), (2), (3);
167+
select count(*) from t with (index = pk_t)
168+
"""));
169+
170+
[TestMethod]
171+
public void Select_IndexHint_EqForm_UnknownName_RaisesMsg308()
172+
=> new Simulation().AssertSqlError("""
173+
create table t (id int primary key);
174+
insert t values (1);
175+
select * from t with (index = nope)
176+
""", 308, "Index 'nope' on table 'dbo.t' (specified in the FROM clause) does not exist.");
177+
178+
[TestMethod]
179+
public void Select_IndexHint_BadIdOnPkTable_RaisesMsg307()
180+
=> new Simulation().AssertSqlError("""
181+
create table t (id int primary key);
182+
insert t values (1);
183+
select * from t with (index(99))
184+
""", 307, "Index ID 99 on table 'dbo.t' (specified in the FROM clause) does not exist.");
185+
186+
[TestMethod]
187+
public void Select_IndexHint_Id1OnHeapTable_RaisesMsg307()
188+
=> new Simulation().AssertSqlError("""
189+
create table t (id int, v int);
190+
insert t values (1, 10);
191+
select * from t with (index(1))
192+
""", 307, "Index ID 1 on table 'dbo.t' (specified in the FROM clause) does not exist.");
193+
194+
[TestMethod]
195+
public void Select_IndexHint_Id0OnHeapTable_ReturnsRows()
196+
=> AreEqual(2, new Simulation().ExecuteScalar("""
197+
create table t (id int, v int);
198+
insert t values (1, 10), (2, 20);
199+
select count(*) from t with (index(0))
200+
"""));
201+
202+
[TestMethod]
203+
public void Select_IndexHint_MixedGoodBadInOneList_RaisesMsg308_OnFirstBad()
204+
=> new Simulation().AssertSqlError("""
205+
create table t (id int constraint pk_t primary key);
206+
insert t values (1);
207+
select * from t with (index(nope, pk_t))
208+
""", 308, "Index 'nope' on table 'dbo.t' (specified in the FROM clause) does not exist.");
209+
210+
[TestMethod]
211+
public void Select_IndexHint_MultipleKnown_ReturnsRows()
212+
=> AreEqual(3, new Simulation().ExecuteScalar("""
213+
create table t (id int constraint pk_t primary key, v int);
214+
create index ix_v on t(v);
215+
insert t values (1, 10), (2, 20), (3, 30);
216+
select count(*) from t with (index(pk_t, ix_v))
217+
"""));
218+
219+
[TestMethod]
220+
public void Select_IndexHint_SchemaQualified_ErrorEmbedsQualifier()
221+
{
222+
var sim = new Simulation();
223+
_ = sim.ExecuteNonQuery("""
224+
create schema au;
225+
create table au.t (id int primary key);
226+
""");
227+
sim.AssertSqlError(
228+
"select * from au.t with (index(nope))",
229+
308, "Index 'nope' on table 'au.t' (specified in the FROM clause) does not exist.");
230+
}
231+
232+
[TestMethod]
233+
public void Select_IndexHint_NegativeIntegerArg_RaisesMsg102()
234+
=> new Simulation().AssertSqlError("""
235+
create table t (id int primary key);
236+
select * from t with (index(-1))
237+
""", 102);
238+
133239
[TestMethod]
134240
public void Select_ForceSeek_BareForm_ReturnsRows()
135241
=> AreEqual(3, new Simulation().ExecuteScalar("""

SqlServerSimulator/Errors/SimulatedSqlException.ConcurrencyErrors.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@ internal static SimulatedSqlException NoLockHintNotAllowedOnDmlTarget() =>
5555
internal static SimulatedSqlException IndexHintsOnlyInFromOrOption() =>
5656
new("Index hints are only allowed in a FROM or OPTION clause.", 1069, 15, 1);
5757

58+
/// <summary>
59+
/// Msg 307 — raised when an <c>INDEX(N)</c> hint references an
60+
/// <c>index_id</c> that doesn't exist on the target table. Probe-confirmed
61+
/// verbatim wording against SQL Server 2025 (2026-05-14): the message
62+
/// embeds the (decimal) id and the qualified table name, both 1-part /
63+
/// 2-part qualified consistently with the <c>FROM</c>-clause spelling. The
64+
/// rejection only fires for <c>FROM</c>-source / JOIN-RHS positions
65+
/// because DML targets short-circuit on <see cref="IndexHintsOnlyInFromOrOption"/>
66+
/// (Msg 1069) before any per-index validation runs.
67+
/// </summary>
68+
internal static SimulatedSqlException IndexHintIdNotFound(int indexId, string qualifiedTableName) =>
69+
new($"Index ID {indexId} on table '{qualifiedTableName}' (specified in the FROM clause) does not exist.", 307, 16, 1);
70+
71+
/// <summary>
72+
/// Msg 308 — raised when an <c>INDEX(name)</c> or <c>INDEX = name</c>
73+
/// hint references an index name that doesn't exist on the target table.
74+
/// Matched case-insensitively against PRIMARY KEY / UNIQUE constraint
75+
/// names plus the table's <c>Indexes</c> list. Probe-confirmed verbatim
76+
/// wording — same single-quote convention and "(specified in the FROM
77+
/// clause)" suffix as Msg 307.
78+
/// </summary>
79+
internal static SimulatedSqlException IndexHintNameNotFound(string indexName, string qualifiedTableName) =>
80+
new($"Index '{indexName}' on table '{qualifiedTableName}' (specified in the FROM clause) does not exist.", 308, 16, 1);
81+
5882
/// <summary>
5983
/// Msg 3952 — raised when a session whose
6084
/// <see cref="SimulatedDbConnection.SessionIsolationLevel"/> is

0 commit comments

Comments
 (0)