Skip to content

Commit f895f88

Browse files
committed
DML target hints (parse-and-discard) — INSERT / MERGE target now ship WITH (hint [, …]) and UPDATE / DELETE / MERGE / INSERT all align with real SQL Server on legacy-bare-paren rejection. Selection.ParseOptionalTableHints gains an allowLegacyParenForm parameter (default true; FROM-source / JOIN-RHS callers keep current behavior, every DML target call site passes false). The closed TableHintNames accept-list, the WITH (…) grammar with bare / = literal / (arg-list) argument shapes, and the Msg 321 unknown-name rejection are reused unchanged across all four DML targets. **INSERT** target hints sit between target name and the optional column list (insert into t with (tablock) (a, b) values (1, 2)); the INTO keyword stays optional; OUTPUT clauses compose; multi-hint WITH (tablock, holdlock) chains accept; table-variable targets short-circuit via BatchContext.IsTableVariableName so insert into @t with (…) falls through to Msg 102 (matches real SQL Server's Msg 156 rejection by code if not wording). Legacy (TABLOCK) form on INSERT is structurally unreachable — the paren is always a column list, surfacing Msg 207 on the would-be hint name (probe-confirmed). **MERGE** target uses hint-then-alias placement (merge into t with (tablock) as x using …) — opposite of FROM / UPDATE / DELETE — confirmed by probe: alias-then-hint raises Msg 156 on real SQL Server. **UPDATE / DELETE** existing call sites flip to allowLegacyParenForm: false, removing a pre-existing simulator divergence — probe-confirmed (2026-05-14) that real SQL Server rejects update t (tablock) set … and delete from t (tablock) where … with Msg 102. **MERGE source** stays unwired — the simulator's USING parser requires parenthesization (USING (select …) as s) and probe-confirmed real SQL Server rejects WITH-hints on that shape anyway (Msg 156); bare-table USING is its own deferred grammar gap. 17 new QueryHintTests cover INSERT (8 cases: WITH no-col-list / explicit-col-list / no-INTO / multi-hint / OUTPUT compose / unknown-name Msg 321 / legacy-form-parses-as-column-list Msg 207 / hint-after-col-list Msg 102 / @t Msg 102 / temp-table accept) + MERGE target (6 cases: hint-then-alias / no-alias / multi-hint / alias-then-hint Msg 102 / legacy-form Msg 102 / unknown-name Msg 321) + UPDATE / DELETE legacy-paren-form rejection. The previously-passing Update_LegacyParenForm_AppliesChange test now asserts Msg 102 instead. CLAUDE.md "Query-hint gaps" rewritten — INSERT-target gap removed from the deferred list; MERGE-source-hints + Msg 1065/1069 DML-target-specific rejections added as the remaining gaps; per-site placement table added to docs/claude/query-hints.md.
1 parent c95032d commit f895f88

8 files changed

Lines changed: 301 additions & 32 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
180180
- **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.
181181
- **`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.
182182
- `hierarchyid`, `geography`, `geometry`.
183-
- **Query-hint gaps** — table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / UPDATE / DELETE targets, both `WITH (…)` and the legacy `(…)`-without-`WITH` forms) and statement-level `OPTION (…)` hints ship via parse-and-discard (see [`docs/claude/query-hints.md`](docs/claude/query-hints.md)). 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. Two narrow gaps remain: (1) **`INSERT INTO t WITH (TABLOCK) …`** target hints aren't parsed — the helper is reusable but the INSERT call site isn't wired (deferred); (2) **hint-conflict detection** (`Msg 1047` for `NOLOCK + XLOCK`, `Msg 308` for unknown `INDEX(name)`) isn't enforced — the simulator has no lock state or index dispatch to conflict over. (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.)
183+
- **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: (1) **MERGE source hints** (`USING t AS s WITH (NOLOCK)`) aren't parsed — the simulator only supports the parenthesized `USING (SELECT/VALUES …)` form, and probe-confirmed real SQL Server rejects WITH on that shape anyway (Msg 156); bare-table MERGE USING is its own deferred grammar; (2) **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. (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.)
184184

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

SqlServerSimulator.Tests/QueryHintTests.cs

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,20 @@ update t with (rowlock) set v = v + 1
138138
}
139139

140140
[TestMethod]
141-
public void Update_LegacyParenForm_AppliesChange()
142-
{
143-
var sim = new Simulation();
144-
_ = sim.ExecuteNonQuery("""
141+
public void Update_LegacyParenForm_RaisesMsg102()
142+
=> new Simulation().AssertSqlError("""
145143
create table t (id int primary key, v int);
146144
insert t values (1, 100);
147145
update t (tablock) set v = 999
148-
""");
149-
AreEqual(999, sim.ExecuteScalar("select v from t where id = 1"));
150-
}
146+
""", 102);
147+
148+
[TestMethod]
149+
public void Delete_LegacyParenForm_RaisesMsg102()
150+
=> new Simulation().AssertSqlError("""
151+
create table t (id int primary key);
152+
insert t values (1);
153+
delete from t (tablock) where id = 1
154+
""", 102);
151155

152156
[TestMethod]
153157
public void Update_UnknownHint_RaisesMsg321()
@@ -294,4 +298,184 @@ update t with (tablock) set v = 999
294298
""");
295299
AreEqual(999, sim.ExecuteScalar("select v from t where id = 1"));
296300
}
301+
302+
// --------------- INSERT target hints ---------------
303+
//
304+
// Probe-confirmed (2026-05-14): INSERT accepts WITH (hint [, …]) only,
305+
// between target name and column list / VALUES. The legacy bare-paren
306+
// form `INSERT t (TABLOCK) …` is always a column list — probe surfaces
307+
// Msg 207 'Invalid column name TABLOCK' rather than parsing it as a
308+
// hint. Hint after column list raises Msg 156 / Msg 102. Table-variable
309+
// targets reject hints entirely.
310+
311+
[TestMethod]
312+
public void Insert_WithHint_NoColumnList_AcceptsAsNoop()
313+
{
314+
var sim = new Simulation();
315+
_ = sim.ExecuteNonQuery("""
316+
create table t (id int identity primary key, name nvarchar(50));
317+
insert into t with (tablock) values (N'a'), (N'b')
318+
""");
319+
AreEqual(2, sim.ExecuteScalar("select count(*) from t"));
320+
}
321+
322+
[TestMethod]
323+
public void Insert_WithHint_ExplicitColumnList_AcceptsAsNoop()
324+
{
325+
var sim = new Simulation();
326+
_ = sim.ExecuteNonQuery("""
327+
create table t (id int identity primary key, name nvarchar(50));
328+
insert into t with (tablock) (name) values (N'a')
329+
""");
330+
AreEqual(1, sim.ExecuteScalar("select count(*) from t"));
331+
}
332+
333+
[TestMethod]
334+
public void Insert_WithHint_NoInto_AcceptsAsNoop()
335+
=> AreEqual(1, new Simulation().ExecuteScalar("""
336+
create table t (id int identity primary key, name nvarchar(50));
337+
insert t with (tablock) values (N'a');
338+
select count(*) from t
339+
"""));
340+
341+
[TestMethod]
342+
public void Insert_WithHint_MultipleHints_AcceptsAsNoop()
343+
=> AreEqual(1, new Simulation().ExecuteScalar("""
344+
create table t (id int identity primary key, name nvarchar(50));
345+
insert into t with (tablock, holdlock) values (N'a');
346+
select count(*) from t
347+
"""));
348+
349+
[TestMethod]
350+
public void Insert_WithHint_OutputClause_AcceptsAsNoop()
351+
=> AreEqual(1, new Simulation().ExecuteScalar("""
352+
create table t (id int identity primary key, name nvarchar(50));
353+
insert into t with (tablock) output inserted.id values (N'a')
354+
"""));
355+
356+
[TestMethod]
357+
public void Insert_WithHint_UnknownHint_RaisesMsg321()
358+
=> new Simulation().AssertSqlError("""
359+
create table t (id int identity primary key, name nvarchar(50));
360+
insert into t with (banana) values (N'a')
361+
""", 321, "\"banana\" is not a recognized table hints option.");
362+
363+
[TestMethod]
364+
public void Insert_LegacyParenForm_ParsesAsColumnList_RaisesMsg207()
365+
{
366+
// Probe-confirmed: real SQL Server parses `(TABLOCK)` as a column
367+
// list and raises Msg 207. The simulator's column resolver throws
368+
// InvalidColumnName from ResolveInsertTargetColumn — matching code
369+
// and wording.
370+
var ex = new Simulation().AssertSqlError("""
371+
create table t (id int identity primary key, name nvarchar(50));
372+
insert into t (TABLOCK) values (N'a')
373+
""", 207);
374+
Contains("TABLOCK", ex.Message);
375+
}
376+
377+
[TestMethod]
378+
public void Insert_HintAfterColumnList_RaisesMsg102()
379+
=> new Simulation().AssertSqlError("""
380+
create table t (id int identity primary key, name nvarchar(50));
381+
insert into t (name) with (tablock) values (N'a')
382+
""", 102);
383+
384+
[TestMethod]
385+
public void Insert_HintOnTableVariable_RaisesMsg102()
386+
=> new Simulation().AssertSqlError("""
387+
declare @t table (id int, name nvarchar(50));
388+
insert into @t with (tablock) values (1, N'a')
389+
""", 102);
390+
391+
[TestMethod]
392+
public void Insert_HintOnTempTable_AcceptsAsNoop()
393+
=> AreEqual(2, new Simulation().ExecuteScalar("""
394+
create table #tmp (id int);
395+
insert into #tmp with (tablock) values (1), (2);
396+
select count(*) from #tmp
397+
"""));
398+
399+
// --------------- MERGE target hints ---------------
400+
//
401+
// Probe-confirmed (2026-05-14): MERGE target uses hint-then-alias
402+
// placement — `MERGE INTO t WITH (TABLOCK) AS x USING …` works,
403+
// `MERGE INTO t AS x WITH (TABLOCK) …` raises Msg 156. Opposite of
404+
// FROM / UPDATE / DELETE which are alias-then-hint. Legacy bare-paren
405+
// form rejected with Msg 102.
406+
407+
[TestMethod]
408+
public void Merge_TargetWithHint_AliasAfter_AcceptsAsNoop()
409+
{
410+
var sim = new Simulation();
411+
_ = sim.ExecuteNonQuery("""
412+
create table tgt (id int primary key, v int);
413+
create table src (id int primary key, v int);
414+
insert tgt values (1, 10);
415+
insert src values (1, 100), (2, 200);
416+
merge into tgt with (tablock) as t
417+
using (select id, v from src) as s on s.id = t.id
418+
when matched then update set v = s.v
419+
when not matched by target then insert (id, v) values (s.id, s.v);
420+
""");
421+
AreEqual(100, sim.ExecuteScalar("select v from tgt where id = 1"));
422+
AreEqual(200, sim.ExecuteScalar("select v from tgt where id = 2"));
423+
}
424+
425+
[TestMethod]
426+
public void Merge_TargetWithHint_NoAlias_AcceptsAsNoop()
427+
{
428+
var sim = new Simulation();
429+
_ = sim.ExecuteNonQuery("""
430+
create table tgt (id int primary key, v int);
431+
create table src (id int primary key, v int);
432+
insert src values (1, 100);
433+
merge into tgt with (tablock)
434+
using (select id, v from src) as s on s.id = tgt.id
435+
when not matched by target then insert (id, v) values (s.id, s.v);
436+
""");
437+
AreEqual(100, sim.ExecuteScalar("select v from tgt where id = 1"));
438+
}
439+
440+
[TestMethod]
441+
public void Merge_TargetMultipleHints_AcceptsAsNoop()
442+
=> AreEqual(1, new Simulation().ExecuteScalar("""
443+
create table tgt (id int primary key, v int);
444+
create table src (id int primary key, v int);
445+
insert src values (1, 100);
446+
merge into tgt with (tablock, holdlock) as t
447+
using (select id, v from src) as s on s.id = t.id
448+
when not matched by target then insert (id, v) values (s.id, s.v);
449+
select count(*) from tgt
450+
"""));
451+
452+
[TestMethod]
453+
public void Merge_AliasThenHint_RaisesMsg102()
454+
=> new Simulation().AssertSqlError("""
455+
create table tgt (id int primary key, v int);
456+
create table src (id int primary key, v int);
457+
merge into tgt as t with (tablock)
458+
using (select id, v from src) as s on s.id = t.id
459+
when not matched by target then insert (id, v) values (s.id, s.v);
460+
""", 102);
461+
462+
[TestMethod]
463+
public void Merge_LegacyParenForm_RaisesMsg102()
464+
=> new Simulation().AssertSqlError("""
465+
create table tgt (id int primary key, v int);
466+
create table src (id int primary key, v int);
467+
merge into tgt (tablock) as t
468+
using (select id, v from src) as s on s.id = t.id
469+
when not matched by target then insert (id, v) values (s.id, s.v);
470+
""", 102);
471+
472+
[TestMethod]
473+
public void Merge_UnknownHint_RaisesMsg321()
474+
=> new Simulation().AssertSqlError("""
475+
create table tgt (id int primary key, v int);
476+
create table src (id int primary key, v int);
477+
merge into tgt with (banana) as t
478+
using (select id, v from src) as s on s.id = t.id
479+
when not matched by target then insert (id, v) values (s.id, s.v);
480+
""", 321, "\"banana\" is not a recognized table hints option.");
297481
}

SqlServerSimulator/Parser/Selection.Hints.cs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,23 @@ internal sealed partial class Selection
7474

7575
/// <summary>
7676
/// Consumes an optional table-hint clause after a FROM source / JOIN-RHS
77-
/// table name (or after an UPDATE / DELETE / INSERT target). Both the
78-
/// standard <c>WITH (hint [, …])</c> and the legacy <c>(hint [, …])</c>
79-
/// (no <c>WITH</c>) forms are accepted; the legacy form is disambiguated
80-
/// from a derived-table column-alias list by peeking at the first inner
81-
/// token and only consuming when it matches <see cref="TableHintNames"/>.
82-
/// On entry the cursor sits at the token immediately following the
83-
/// alias (or the bare table name if no alias was present). On exit the
84-
/// cursor sits at the next un-consumed lookahead token (WHERE / JOIN /
85-
/// comma / <c>;</c> / null).
77+
/// table name (or after an INSERT / UPDATE / DELETE / MERGE target /
78+
/// MERGE source). The standard <c>WITH (hint [, …])</c> form is always
79+
/// accepted; the legacy <c>(hint [, …])</c> (no <c>WITH</c>) form is
80+
/// only accepted when <paramref name="allowLegacyParenForm"/> is
81+
/// <c>true</c>. FROM / JOIN-RHS pass <c>true</c>; INSERT, UPDATE, DELETE,
82+
/// and MERGE all pass <c>false</c> (probe-confirmed: real SQL Server
83+
/// rejects the bare-paren form on every DML target, raising either
84+
/// Msg 102 for UPDATE / DELETE / MERGE or treating the paren as a
85+
/// column list on INSERT). The legacy form is disambiguated from a
86+
/// derived-table column-alias list by peeking at the first inner token
87+
/// and only consuming when it matches <see cref="TableHintNames"/>. On
88+
/// entry the cursor sits at the token immediately following the alias
89+
/// (or the bare table name if no alias was present). On exit the cursor
90+
/// sits at the next un-consumed lookahead token (WHERE / JOIN / comma /
91+
/// <c>;</c> / null).
8692
/// </summary>
87-
internal static void ParseOptionalTableHints(ParserContext context)
93+
internal static void ParseOptionalTableHints(ParserContext context, bool allowLegacyParenForm = true)
8894
{
8995
if (context.Token is ReservedKeyword { Keyword: Keyword.With })
9096
{
@@ -94,7 +100,7 @@ internal static void ParseOptionalTableHints(ParserContext context)
94100
ConsumeTableHintListBody(context);
95101
return;
96102
}
97-
if (context.Token is Operator { Character: '(' })
103+
if (allowLegacyParenForm && context.Token is Operator { Character: '(' })
98104
{
99105
var checkpoint = context.SaveCheckpoint();
100106
context.MoveNextRequired();

SqlServerSimulator/Simulation/Simulation.Delete.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ private static SimulatedStatementOutcome ParseDelete(ParserContext context)
5353
_ = context.Batch.TryResolveTable(leadingIdent, out leadingTable);
5454
}
5555
context.MoveNextOptional();
56-
Selection.ParseOptionalTableHints(context);
56+
Selection.ParseOptionalTableHints(context, allowLegacyParenForm: false);
5757

5858
// OUTPUT requires a known target. INSERTED isn't a valid qualifier
5959
// in DELETE OUTPUT (probe-confirmed Msg 4104). Alias-form multi-

0 commit comments

Comments
 (0)