Skip to content

Commit efb6b43

Browse files
committed
Query hints (parse-and-discard): table hints (WITH (NOLOCK [, ...]) plus the legacy (...)-without-WITH form) on FROM-source / JOIN-RHS / UPDATE / DELETE target positions, statement-level OPTION (...) hints expanded from MAXRECURSION-only to the full standard hint surface (RECOMPILE / MAXDOP / FAST / LOOP|HASH|MERGE JOIN / FORCE ORDER / KEEP|KEEPFIXED|ROBUST PLAN / OPTIMIZE FOR (UNKNOWN) / USE HINT(...) / EXPAND VIEWS / IGNORE_NONCLUSTERED_COLUMNSTORE_INDEX / NO_PERFORMANCE_SPOOL / QUERYTRACEON / TABLE HINT / PARAMETERIZATION / HASH|ORDER GROUP / CONCAT|HASH|MERGE UNION). Simulator doesn't model locking / isolation / planner choices / indexes so all recognized hints are no-ops; value of the bundle is grammar compatibility with applications and EF Core pipelines that emit hints via TagWith / raw SQL. New Selection.Hints.cs partial centralizes the hint vocabulary in two case-insensitive HashSets (TableHintNames ~26 entries / OptionHintFirstWords ~22 entries) plus the ParseOptionalTableHints / ParseOptionClause / SkipBalancedParens helpers. Closed-list rejection per probe-confirmed wording (SQL Server 2025): unknown table hint -> Msg 321 ("<name>" is not a recognized table hints option. with surrounding double-quotes verbatim); unknown OPTION hint -> Msg 102 generic syntax error (probe surprise: OPTION clause has no dedicated unknown-hint code unlike table hints). MAXRECURSION retains its existing runtime effect (override in-scope CteBinding.MaxRecursion); every other recognized hint discards via skip-to-comma-or-paren walking with SkipBalancedParens for nested arg lists (INDEX(IX_foo(c1, c2)), USE HINT('FORCE_LEGACY_CARDINALITY_ESTIMATION'), OPTIMIZE FOR (@p UNKNOWN)). Legacy (hint)-without-WITH form disambiguated from a derived-table column-alias list by peeking one token past (; only consumes when the inner token matches TableHintNames. Wire-up: ParseSingleFromSource calls ParseOptionalTableHints after each ConsumeOptionalAlias (heap-table, view, table-variable cases); ParseUpdate / ParseDelete call it after their target-name parse. ParseInner's post-projection token-loop gets Keyword.Option added to the goto-ExitWhileTokenLoop case alongside the existing set-op terminators so OPTION reaches ParseQueryExpression's outer handler. New QueryErrors factory UnrecognizedTableHint(ReadOnlySpan<char> hintName) for Msg 321. 33 new QueryHintTests cover: every recognized table-hint shape (bare name / = literal / (args)), the WITH and legacy forms, JOIN-RHS hints, UPDATE-target + DELETE-target both forms, every OPTION accept-list entry including multi-word and parenthesized-arg variants, MAXRECURSION's preserved runtime effect, plus the Msg 321 / Msg 102 unknown-hint rejection paths verbatim. One test pins the alias-vs-hint distinction explicitly: FROM t nolock (no WITH, no parens) parses as bare-alias on both real SQL Server and the simulator — nolock isn't a reserved keyword, so the standard ConsumeOptionalAlias Name-token path consumes it, identical to FROM t banana; there is no "deprecated bare-no-paren hint form" to model. New docs/claude/query-hints.md deep-dive doc; CLAUDE.md gains a Feature reference trigger entry and a Not-modeled entry calling out the two narrow remaining gaps (INSERT target hints deferred to a separate bundle - the helper is reusable but the INSERT call site isn't wired; hint-conflict detection Msg 1047 / Msg 308 not enforced since we have no state to conflict over).
1 parent 31284f5 commit efb6b43

8 files changed

Lines changed: 693 additions & 46 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 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 `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).
154154
- **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, `ALTER TABLE … ADD [COLUMN]` / `DROP COLUMN` / `ALTER COLUMN`, `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, the type-change conversion (`SqlValue.CoerceTo` surfacing Msg 220 / 245 / 241 / 8115 / 2628), the nullability-flip Msg 515 scan, the Msg 4928 COMPUTED / rowversion rejection, the Msg 5074 ALTER COLUMN blocker walker (PK/UQ/FK/computed-deps unconditional; index when SqlType-subclass changes), or `sys.check_constraints` / `sys.key_constraints` / `sys.default_constraints`**[`docs/claude/alter-table.md`](docs/claude/alter-table.md).
155155
- **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).
156+
- **Touching table hints (`WITH (NOLOCK [, …])` on FROM sources / JOIN-RHS / UPDATE / DELETE targets, the legacy `(hint)` without-WITH form, `Msg 321` unknown-hint rejection), statement-level `OPTION (…)` hints (`RECOMPILE` / `MAXDOP` / `FAST` / `LOOP|HASH|MERGE JOIN` / `FORCE ORDER` / `OPTIMIZE FOR (UNKNOWN)` / `USE HINT(…)` / `MAXRECURSION` with its CTE-binding override), the `TableHintNames` / `OptionHintFirstWords` closed accept-lists, or `SkipBalancedParens` argument consumption**[`docs/claude/query-hints.md`](docs/claude/query-hints.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
@@ -179,6 +180,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
179180
- **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.
180181
- **`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.
181182
- `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.)
182184

183185
## Quirks (modeled, not byte-identical to SQL Server)
184186

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Table hints (<c>WITH (NOLOCK [, …])</c>) on FROM sources / JOIN-RHS /
7+
/// UPDATE / DELETE targets, plus statement-level <c>OPTION (…)</c> hints —
8+
/// parsed and discarded for grammar compatibility (no locking / isolation
9+
/// modeling). Probe-confirmed rejection wording: unknown table-hint → Msg
10+
/// 321 verbatim; unknown OPTION hint → generic Msg 102 (matches probe
11+
/// surprise that the OPTION clause has no dedicated unknown-hint code).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class QueryHintTests
15+
{
16+
[TestMethod]
17+
public void Select_WithNoLock_ReturnsRows()
18+
=> AreEqual(3, new Simulation().ExecuteScalar("""
19+
create table t (id int primary key);
20+
insert t values (1), (2), (3);
21+
select count(*) from t with (nolock)
22+
"""));
23+
24+
[TestMethod]
25+
public void Select_WithMultipleHints_ReturnsRows()
26+
=> AreEqual(3, new Simulation().ExecuteScalar("""
27+
create table t (id int primary key);
28+
insert t values (1), (2), (3);
29+
select count(*) from t with (nolock, holdlock, readpast)
30+
"""));
31+
32+
[TestMethod]
33+
public void Select_LegacyParenForm_NoWith_ReturnsRows()
34+
=> AreEqual(3, new Simulation().ExecuteScalar("""
35+
create table t (id int primary key);
36+
insert t values (1), (2), (3);
37+
select count(*) from t (nolock)
38+
"""));
39+
40+
[TestMethod]
41+
public void Select_HintAfterAlias_ReturnsRows()
42+
=> AreEqual(3, new Simulation().ExecuteScalar("""
43+
create table t (id int primary key);
44+
insert t values (1), (2), (3);
45+
select count(*) from t as x with (nolock)
46+
"""));
47+
48+
[TestMethod]
49+
public void Select_HintAfterBareAlias_ReturnsRows()
50+
=> AreEqual(3, new Simulation().ExecuteScalar("""
51+
create table t (id int primary key);
52+
insert t values (1), (2), (3);
53+
select count(*) from t x with (nolock)
54+
"""));
55+
56+
[TestMethod]
57+
public void Select_IndexHint_NumericArg_ReturnsRows()
58+
=> AreEqual(3, new Simulation().ExecuteScalar("""
59+
create table t (id int primary key);
60+
insert t values (1), (2), (3);
61+
select count(*) from t with (index(0))
62+
"""));
63+
64+
/// <summary>
65+
/// Simulator doesn't model indexes, so the named-index reference parses
66+
/// and is discarded. Real SQL Server raises Msg 308 on a wrong name;
67+
/// the parse-and-ignore stance is the consistent posture for hints.
68+
/// </summary>
69+
[TestMethod]
70+
public void Select_IndexHint_NamedArg_ReturnsRows()
71+
=> AreEqual(3, new Simulation().ExecuteScalar("""
72+
create table t (id int primary key);
73+
insert t values (1), (2), (3);
74+
select count(*) from t with (index(IX_does_not_exist))
75+
"""));
76+
77+
[TestMethod]
78+
public void Select_ForceSeek_BareForm_ReturnsRows()
79+
=> AreEqual(3, new Simulation().ExecuteScalar("""
80+
create table t (id int primary key);
81+
insert t values (1), (2), (3);
82+
select count(*) from t with (forceseek)
83+
"""));
84+
85+
[TestMethod]
86+
public void Select_SpatialWindowMaxCells_EqForm_ReturnsRows()
87+
=> AreEqual(3, new Simulation().ExecuteScalar("""
88+
create table t (id int primary key);
89+
insert t values (1), (2), (3);
90+
select count(*) from t with (SPATIAL_WINDOW_MAX_CELLS = 1024)
91+
"""));
92+
93+
[TestMethod]
94+
public void Select_UnknownHint_RaisesMsg321()
95+
=> new Simulation().AssertSqlError("""
96+
create table t (id int primary key);
97+
select * from t with (banana)
98+
""", 321, "\"banana\" is not a recognized table hints option.");
99+
100+
[TestMethod]
101+
public void Select_UnknownHint_AmongValidOnes_RaisesMsg321()
102+
=> new Simulation().AssertSqlError("""
103+
create table t (id int primary key);
104+
select * from t with (nolock, foobar, readpast)
105+
""", 321, "\"foobar\" is not a recognized table hints option.");
106+
107+
[TestMethod]
108+
public void Join_RhsHint_ReturnsRows()
109+
=> AreEqual(3, new Simulation().ExecuteScalar("""
110+
create table t (id int primary key);
111+
insert t values (1), (2), (3);
112+
select count(*)
113+
from t a
114+
inner join t b with (nolock) on a.id = b.id
115+
"""));
116+
117+
[TestMethod]
118+
public void Join_LegacyParenRhs_NoWith_ReturnsRows()
119+
=> AreEqual(3, new Simulation().ExecuteScalar("""
120+
create table t (id int primary key);
121+
insert t values (1), (2), (3);
122+
select count(*)
123+
from t a
124+
left join t b (nolock) on a.id = b.id
125+
"""));
126+
127+
[TestMethod]
128+
public void Update_WithHint_AppliesChange()
129+
{
130+
var sim = new Simulation();
131+
_ = sim.ExecuteNonQuery("""
132+
create table t (id int primary key, v int);
133+
insert t values (1, 100), (2, 200);
134+
update t with (rowlock) set v = v + 1
135+
""");
136+
AreEqual(101, sim.ExecuteScalar("select v from t where id = 1"));
137+
AreEqual(201, sim.ExecuteScalar("select v from t where id = 2"));
138+
}
139+
140+
[TestMethod]
141+
public void Update_LegacyParenForm_AppliesChange()
142+
{
143+
var sim = new Simulation();
144+
_ = sim.ExecuteNonQuery("""
145+
create table t (id int primary key, v int);
146+
insert t values (1, 100);
147+
update t (tablock) set v = 999
148+
""");
149+
AreEqual(999, sim.ExecuteScalar("select v from t where id = 1"));
150+
}
151+
152+
[TestMethod]
153+
public void Update_UnknownHint_RaisesMsg321()
154+
=> new Simulation().AssertSqlError("""
155+
create table t (id int primary key, v int);
156+
insert t values (1, 100);
157+
update t with (banana) set v = 999
158+
""", 321, "\"banana\" is not a recognized table hints option.");
159+
160+
[TestMethod]
161+
public void Delete_BareForm_WithHint_RemovesRows()
162+
{
163+
var sim = new Simulation();
164+
_ = sim.ExecuteNonQuery("""
165+
create table t (id int primary key);
166+
insert t values (1), (2), (3);
167+
delete from t with (tablock) where id = 2
168+
""");
169+
AreEqual(2, sim.ExecuteScalar("select count(*) from t"));
170+
}
171+
172+
[TestMethod]
173+
public void Delete_AliasForm_WithHint_RemovesRows()
174+
{
175+
var sim = new Simulation();
176+
_ = sim.ExecuteNonQuery("""
177+
create table t (id int primary key);
178+
insert t values (1), (2), (3);
179+
delete a from t a with (tablock) where a.id = 2
180+
""");
181+
AreEqual(2, sim.ExecuteScalar("select count(*) from t"));
182+
}
183+
184+
[TestMethod]
185+
public void Delete_UnknownHint_RaisesMsg321()
186+
=> new Simulation().AssertSqlError("""
187+
create table t (id int primary key);
188+
delete from t with (banana)
189+
""", 321, "\"banana\" is not a recognized table hints option.");
190+
191+
[TestMethod]
192+
public void Option_Recompile_AcceptedAsNoop()
193+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (recompile)"));
194+
195+
[TestMethod]
196+
public void Option_MaxDop_AcceptedAsNoop()
197+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (maxdop 4)"));
198+
199+
[TestMethod]
200+
public void Option_Fast_AcceptedAsNoop()
201+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (fast 100)"));
202+
203+
[TestMethod]
204+
public void Option_LoopJoin_AcceptedAsNoop()
205+
=> AreEqual(3, new Simulation().ExecuteScalar("""
206+
create table t (id int primary key);
207+
insert t values (1), (2), (3);
208+
select count(*) from t a inner join t b on a.id = b.id option (loop join)
209+
"""));
210+
211+
[TestMethod]
212+
public void Option_HashJoin_AcceptedAsNoop()
213+
=> AreEqual(3, new Simulation().ExecuteScalar("""
214+
create table t (id int primary key);
215+
insert t values (1), (2), (3);
216+
select count(*) from t a inner join t b on a.id = b.id option (hash join)
217+
"""));
218+
219+
[TestMethod]
220+
public void Option_ForceOrder_AcceptedAsNoop()
221+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (force order)"));
222+
223+
[TestMethod]
224+
public void Option_KeepFixedPlan_AcceptedAsNoop()
225+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (keepfixed plan)"));
226+
227+
[TestMethod]
228+
public void Option_OptimizeForUnknown_AcceptedAsNoop()
229+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (optimize for unknown)"));
230+
231+
[TestMethod]
232+
public void Option_UseHint_QuotedArg_AcceptedAsNoop()
233+
=> AreEqual(1, new Simulation().ExecuteScalar("""
234+
select 1 option (use hint('FORCE_LEGACY_CARDINALITY_ESTIMATION'))
235+
"""));
236+
237+
[TestMethod]
238+
public void Option_MultipleHints_AcceptedAsNoop()
239+
=> AreEqual(1, new Simulation().ExecuteScalar("select 1 option (recompile, maxdop 4)"));
240+
241+
[TestMethod]
242+
public void Option_UnknownHint_RaisesMsg102()
243+
=> new Simulation().AssertSqlError("select 1 option (banana)", 102);
244+
245+
[TestMethod]
246+
public void Option_MaxRecursionStillWorks()
247+
{
248+
// Closed-list parser preserves MAXRECURSION's runtime effect — the
249+
// CTE recursion limit override path is the only OPTION hint with
250+
// observable behavior.
251+
var ex = new Simulation().AssertSqlError("""
252+
with c as (
253+
select 1 as n
254+
union all
255+
select n + 1 from c where n < 200
256+
)
257+
select count(*) from c option (maxrecursion 50)
258+
""", 530);
259+
Contains("50", ex.Message);
260+
}
261+
262+
[TestMethod]
263+
public void Select_BareIdentifier_NotHint_ParsesAsAlias()
264+
{
265+
// `FROM t nolock` (no WITH, no parens) is alias parsing, not a
266+
// deprecated hint shape — nolock / readpast / etc. aren't reserved
267+
// keywords, so they're valid bare aliases. Same path as `FROM t a`.
268+
// Real SQL Server treats this identically; the simulator's bare-alias
269+
// branch in ConsumeOptionalAlias matches by Name token, which
270+
// UnquotedString satisfies.
271+
var sim = new Simulation();
272+
_ = sim.ExecuteNonQuery("""
273+
create table h1 (id int primary key, v int);
274+
insert h1 values (1, 100), (2, 200)
275+
""");
276+
AreEqual(2, sim.ExecuteScalar("select count(*) from h1 nolock"));
277+
AreEqual(100, sim.ExecuteScalar("select nolock.v from h1 nolock where nolock.id = 1"));
278+
AreEqual(2, sim.ExecuteScalar("select count(*) from h1 banana"));
279+
AreEqual(100, sim.ExecuteScalar("select banana.v from h1 banana where banana.id = 1"));
280+
}
281+
282+
[TestMethod]
283+
public void Update_BareAlias_HintAfter_AppliesChange()
284+
{
285+
// UPDATE target-hint position is between target name and SET, not
286+
// between alias and SET — the alias form `UPDATE t a WITH (...)`
287+
// isn't probe-confirmed and not modeled here. This test pins the
288+
// unambiguous shape.
289+
var sim = new Simulation();
290+
_ = sim.ExecuteNonQuery("""
291+
create table t (id int primary key, v int);
292+
insert t values (1, 100);
293+
update t with (tablock) set v = 999
294+
""");
295+
AreEqual(999, sim.ExecuteScalar("select v from t where id = 1"));
296+
}
297+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,4 +453,14 @@ internal static SimulatedSqlException StringSplitSeparatorMustBeSingleChar() =>
453453
/// </summary>
454454
internal static SimulatedSqlException StringSplitInvalidEnableOrdinal(long value) =>
455455
new($"Argument value {value.ToString(System.Globalization.CultureInfo.InvariantCulture)} is invalid for argument 3 of string_split function.", 4199, 16, 1);
456+
457+
/// <summary>
458+
/// Mimics SQL Server's Msg 321 — fired when a <c>WITH (...)</c> clause on
459+
/// a table / view / table-variable source (or after an UPDATE / DELETE
460+
/// target) names something that isn't a recognized table-hint keyword.
461+
/// Wording is verbatim from the probe (2026-05-14 against SQL Server
462+
/// 2025), including the surrounding double-quotes on the offending name.
463+
/// </summary>
464+
internal static SimulatedSqlException UnrecognizedTableHint(ReadOnlySpan<char> hintName) =>
465+
new($"\"{new string(hintName)}\" is not a recognized table hints option.", 321, 15, 1);
456466
}

0 commit comments

Comments
 (0)