Skip to content

Commit 68a0535

Browse files
committed
MERGE source bare-table form — USING tbl [AS] alias [WITH (hints)] ships alongside the existing parenthesized USING (VALUES … / SELECT …) AS alias shape. Simulation.Merge.cs:ParseMergeSource forks on the first post-USING token: ( routes to the existing parenthesized parser (carved out as ParseParenthesizedMergeSource for clarity, behavior unchanged); anything else routes to the new ParseBareTableMergeSource, which calls BatchContext.ParseObjectName(context, acceptTableVariable: true), resolves through TryResolveView then TryResolveTable (same precedence as FROM-source), and builds the materializer accordingly — Selection.ForView(resolvedView).Execute(...) for views; heapTable.Heap.EnumerateRows() + DecodeFullRow + EvaluateComputedColumns for heap tables (regular / temp # / table-variable @t all flow through this path uniformly via TryResolveTable). Source schema is the resolved table's full Columns list (including computed) with column names exposed for ON-predicate / SET / INSERT-projection name resolution through the existing sourceColumnNames / sourceSchema arrays. Alias is optional and defaults to the table's leaf name when absent (probe-confirmed: USING src ON src.id = tgt.id works, references go via the bare table name). Selection.ConsumeOptionalAlias promoted from private to internal so both the FROM-source heap-table path and the new MERGE bare-table path can share it. Optional WITH (hint [, …]) table hints sit alias-then-hint (same placement as FROM source); Selection.ParseOptionalTableHints gains a third parameter commitOnLegacyParen (default false to preserve existing FROM/JOIN-RHS behavior), which the MERGE bare-table call site passes true so the legacy bare-paren (NOLOCK) form commits to hint-clause interpretation on ( — matches real SQL Server's behavior: USING src AS s (nolock) works as a hint, USING src AS s (x, y) raises Msg 321 with "x is not a recognized table hints option" rather than falling through to a generic Msg 102 (probed against SQL Server 2025 on 2026-05-14). 12 new MergeTests cover: heap table with explicit alias (AS s), bare alias (no AS keyword), no alias (referenced via the table name), schema-qualified au.src, alias-then-WITH (NOLOCK) accepted as no-op, alias-then-legacy-bare-paren-(nolock) accepted, alias-then-(x, y) column-rename rejection (Msg 321 — verbatim probe wording), temp-table source, table-variable source, view source, missing-table (Msg 208), missing-table-variable (Msg 1087). CLAUDE.md MERGE grammar entry expanded to call out the bare-table source kinds and the per-source-form column-rename rule; "MERGE source hints aren't parsed" gap removed from query-hints; new note documents the remaining FROM-source (unknown) commit-on-paren divergence (Msg 102 instead of Msg 207 / Msg 321) which the new flag doesn't yet propagate back to FROM-source callers. docs/claude/dml.md MERGE syntax grammar updated; docs/claude/query-hints.md site table now lists MERGE source (bare-table) and its hint position, with explanatory text replacing the previous "not modeled" note.
1 parent f895f88 commit 68a0535

7 files changed

Lines changed: 337 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Three entry points share one per-connection undo log: implicit (statement-level
118118

119119
### MERGE / OUTPUT
120120
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
121-
- `MERGE INTO target [AS alias] USING (<source>) AS alias [(cols)] ON predicate <when-clause>+ [OUTPUT …]` — source can be `VALUES`, `SELECT`, or a set-op chain. WHEN clauses: `WHEN MATCHED [AND <cond>] THEN UPDATE SET …` / `DELETE`, `WHEN NOT MATCHED [BY TARGET] [AND <cond>] THEN INSERT (…) VALUES (…)`, `WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN UPDATE SET …` / `DELETE`. Multiple AND-conditioned clauses per family allowed; unconditional must be last (Msg 5324). `WHEN NOT MATCHED [BY TARGET]` admits at most one clause (Msg 10714). Trailing `;` required (Msg 10713). `$action` in OUTPUT projects uppercase `INSERT` / `UPDATE` / `DELETE` per affected row. Multi-match: a target row matched by more than one source row raises Msg 8672 only when the chosen action is UPDATE (DELETE is forgiving — multiple matches collapse to one delete). Triggers fire INSERT → UPDATE → DELETE, each kind once with its combined affected rows. See [`docs/claude/dml.md`](docs/claude/dml.md).
121+
- `MERGE INTO target [WITH (hints)] [AS alias] USING <source> [AS alias] [(cols)] ON predicate <when-clause>+ [OUTPUT …]` — source can be `(VALUES …)`, `(SELECT …)`, a set-op chain, or a bare-table / view / temp-table / table-variable / schema-qualified reference (the bare-table form ships independently of the parenthesized form; alias is optional and defaults to the table's leaf name; trailing column-rename list `(c1, c2)` isn't legal on the bare-table form and parses as a hint clause — Msg 321 matches real SQL Server). WHEN clauses: `WHEN MATCHED [AND <cond>] THEN UPDATE SET …` / `DELETE`, `WHEN NOT MATCHED [BY TARGET] [AND <cond>] THEN INSERT (…) VALUES (…)`, `WHEN NOT MATCHED BY SOURCE [AND <cond>] THEN UPDATE SET …` / `DELETE`. Multiple AND-conditioned clauses per family allowed; unconditional must be last (Msg 5324). `WHEN NOT MATCHED [BY TARGET]` admits at most one clause (Msg 10714). Trailing `;` required (Msg 10713). `$action` in OUTPUT projects uppercase `INSERT` / `UPDATE` / `DELETE` per affected row. Multi-match: a target row matched by more than one source row raises Msg 8672 only when the chosen action is UPDATE (DELETE is forgiving — multiple matches collapse to one delete). Triggers fire INSERT → UPDATE → DELETE, each kind once with its combined affected rows. See [`docs/claude/dml.md`](docs/claude/dml.md).
122122

123123
### EF Core adapter coverage
124124
`UseSqlServerSimulator(...)` covers seven SqlParameter-downcast pairs: `DateOnly→date`, `DateTime→date`, `DateTime→smalldatetime`, `TimeOnly→time(N)`, `TimeSpan→time(N)`, `decimal→money`, `decimal→smallmoney`. Without the adapter those mappings throw at SaveChanges. MAX-string family flows through plain `UseSqlServer`.
@@ -166,7 +166,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
166166
- `LIKE` with `COLLATE` override (default collation only).
167167
- `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).
168168
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
169-
- 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+
- 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. (Bare-table USING ships as of 2026-05-14 — regular tables, views, temp-tables, table-variables, schema-qualified references, with alias-then-hint WITH (NOLOCK) support — and removes a previously-listed gap from the query-hints surface.)
170170
- `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.
171171
- Heap allocation tracking (flat page list, no IAM/PFS).
172172
- **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).
@@ -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 / 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.)
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: **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.)
184184

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

0 commit comments

Comments
 (0)