Skip to content

Commit 62f9c08

Browse files
committed
MERGE WHEN MATCHED expansion: full branch-family parser + executor — 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 / DELETE. Source lifted from VALUES-only to any Selection (SELECT / set-op chain), gated by a parse-time materializer that picks VALUES-tuple-eval vs Selection.Execute. Single-pass executor: target × source scan collects per-target match lists, dispatches first-AND-satisfied WHEN clause; queued inserts/updates/deletes apply atomically (PK/UNIQUE validation runs on the union via sentinel-address EnforceKeyConstraintsForUpdate so a constraint violation rolls back the entire MERGE). $action pseudo-column tokenized as a single $action UnquotedString (replaces the default $-as-money + action-as-name split), recognized in OUTPUT through any AS-alias wrapper via IsMergeActionRef drilling past NamedExpression; projects uppercase 'INSERT' / 'UPDATE' / 'DELETE' as nvarchar(10). New MergeOutputProjection handles the 4-way INSERTED / DELETED / source-alias / $action surface that the existing OutputProjection (INSERT-shape) and MutationOutputProjection (UPDATE/DELETE-shape) don't compose. Multi-match guard: target row matched by >1 source row + chosen action UPDATE → Msg 8672; DELETE is forgiving (probe-confirmed). Action-kind rejection: Msg 10711 for INSERT in MATCHED / NOT MATCHED BY SOURCE; Msg 10710 for UPDATE/DELETE in NOT MATCHED BY TARGET; Msg 10714 for multiple NOT MATCHED BY TARGET clauses; Msg 5324 for AND-after-no-AND in MATCHED / NOT MATCHED BY SOURCE; Msg 10713 for missing trailing ;. Triggers fire INSERT → UPDATE → DELETE, each kind once with combined affected rows (probe-confirmed). New contextual keywords (Source, Target) for the BY SOURCE / BY TARGET grammar. 22 new MergeTests cover every branch combination, all error paths, source-subquery + set-op shapes, $action across branches, multi-match guard (UPDATE raises, DELETE doesn't), AND first-match-wins, trigger order, and atomic rollback. CLAUDE.md "Not modeled" rewritten (MERGE WHEN MATCHED branches + $action + source subqueries shipped; CTE-headed sources / MERGE-into-view / $action-into-@t deferred); docs/claude/dml.md expanded with the MERGE deep-dive (grammar table, per-phase execution model, $action + trigger + EF reach notes).
1 parent 35ce1c5 commit 62f9c08

9 files changed

Lines changed: 1586 additions & 243 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,9 @@ Three entry points share one per-connection undo log: implicit (statement-level
116116
- **Temp-table CREATE/DROP participates in the log** via `TempTableCreation` / `TempTableRemoval` `UndoEntry` subtypes (rollback removes from / restores into the connection's `TempTables` dict). Regular CREATE/DROP TABLE is NOT logged — see the corresponding quirk.
117117
- No isolation: uncommitted writes immediately visible to all readers (single-Simulation, single-thread-at-a-time).
118118

119-
### MERGE / OUTPUT (EF SaveChanges shape only)
119+
### MERGE / OUTPUT
120120
- `INSERT ... OUTPUT INSERTED.<col>` (single-row).
121-
- `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch).
122-
- `WHEN MATCHED` parses but throws `NotSupportedException` if its predicate ever evaluates true.
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).
123122

124123
### EF Core adapter coverage
125124
`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`.
@@ -165,7 +164,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
165164
- `CONVERT` / `TRY_CONVERT` style codes other than `0` / `120` / `121`.
166165
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
167166
- `OUTPUT DELETED.*` / `INSERTED.*` star expansion. (`OUTPUT INTO <target>` ships for both `@t` and regular tables — see Table variables below.)
168-
- MERGE source subqueries; MERGE target column refs in `ON`; `WHEN MATCHED` UPDATE/DELETE branches; `$action`.
167+
- 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.
169168
- `PRIMARY KEY` / `UNIQUE` on a computed column (`NotSupportedException`).
170169
- Heap allocation tracking (flat page list, no IAM/PFS).
171170
- **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).

SqlServerSimulator.Tests/MergeTests.cs

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

SqlServerSimulator.Tests/OutputClauseTests.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,6 @@ public void Merge_ExplicitRowVersionInColumnList_RaisesMsg273()
206206
when not matched then insert (id, rv) values (s.id, s.rv);
207207
""", 273);
208208

209-
// Simulator parses WHEN MATCHED but throws if the ON predicate ever steers a source row there.
210-
[TestMethod]
211-
public void Merge_WhenMatchedFires_RaisesNotSupported()
212-
=> _ = Assert.Throws<NotSupportedException>(() => new Simulation().ExecuteNonQuery("""
213-
create table t ( id int, v int );
214-
merge t using (values (1, 99)) as i (id, v) on 1 = 1
215-
when matched then update set v = i.v
216-
when not matched then insert (id, v) values (i.id, i.v);
217-
"""));
218-
219209
[TestMethod]
220210
public void Merge_FirstSourceTuple_DeterminesAliasSchema()
221211
{

SqlServerSimulator.Tests/StatementAtomicityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ insert t values (5, 500)
105105
_ = Throws<DbException>(() =>
106106
_ = connection.CreateCommand(
107107
"merge into t using (values (1, 10), (5, 50), (3, 30)) as src(id, val) " +
108-
"on t.id = src.id " +
108+
"on 1 = 0 " +
109109
"when not matched then insert (id, val) values (src.id, src.val);").ExecuteNonQuery());
110110

111111
// The PK collision on (5, 50) should roll back the (1, 10) insert too.

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,65 @@ internal static SimulatedSqlException NoCorrespondingBeginRollback() =>
7272
internal static SimulatedSqlException CteRequiresPrecedingSemicolon() =>
7373
new("Incorrect syntax near the keyword 'with'. If this statement is a common table expression, an xmlnamespaces clause or a change tracking context clause, the previous statement must be terminated with a semicolon.", 319, 15, 1);
7474

75+
/// <summary>
76+
/// Mimics SQL Server error 5324: a <c>MERGE</c> statement had a
77+
/// <c>WHEN MATCHED</c> (or <c>WHEN NOT MATCHED BY SOURCE</c>) clause
78+
/// carrying a search condition appear <em>after</em> a clause of the
79+
/// same family with no search condition. Real SQL Server requires the
80+
/// unconditional fallback to be last in the family. Probe-confirmed
81+
/// against SQL Server 2025 (2026-05-13): Class 16, State 1, exact
82+
/// wording verbatim. The <paramref name="clauseFamily"/> is either
83+
/// <c>"WHEN MATCHED"</c> or <c>"WHEN NOT MATCHED BY SOURCE"</c> —
84+
/// <c>WHEN NOT MATCHED [BY TARGET]</c> has at most one clause total
85+
/// (Msg 10714) and so doesn't share this path.
86+
/// </summary>
87+
internal static SimulatedSqlException MergeUnconditionalMustBeLast(string clauseFamily) =>
88+
new($"In a MERGE statement, a '{clauseFamily}' clause with a search condition cannot appear after a '{clauseFamily}' clause with no search condition.", 5324, 16, 1);
89+
90+
/// <summary>
91+
/// Mimics SQL Server error 8672: a <c>MERGE</c> statement's
92+
/// <c>WHEN MATCHED ... THEN UPDATE</c> branch attempted to update the
93+
/// same target row from multiple source rows. SQL Server's <c>DELETE</c>
94+
/// matched branch is forgiving (multiple matches collapse to one delete),
95+
/// but <c>UPDATE</c> raises this. Probe-confirmed against SQL Server 2025
96+
/// (2026-05-13): Class 16, State 1, exact wording verbatim.
97+
/// </summary>
98+
internal static SimulatedSqlException MergeMultiMatch() =>
99+
new("The MERGE statement attempted to UPDATE or DELETE the same row more than once. This happens when a target row matches more than one source row. A MERGE statement cannot UPDATE/DELETE the same row of the target table multiple times. Refine the ON clause to ensure a target row matches at most one source row, or use the GROUP BY clause to group the source rows.", 8672, 16, 1);
100+
101+
/// <summary>
102+
/// Mimics SQL Server error 10710: a <c>MERGE</c> statement specified an
103+
/// <c>UPDATE</c> or <c>DELETE</c> action in a <c>WHEN NOT MATCHED</c> /
104+
/// <c>WHEN NOT MATCHED BY TARGET</c> clause where only <c>INSERT</c> is
105+
/// legal. Probe-confirmed against SQL Server 2025 (2026-05-13): Class 15,
106+
/// State 1, exact wording verbatim.
107+
/// </summary>
108+
internal static SimulatedSqlException MergeUpdateNotAllowedInNotMatched() =>
109+
new("An action of type 'UPDATE' is not allowed in the 'WHEN NOT MATCHED' clause of a MERGE statement.", 10710, 15, 1);
110+
111+
/// <summary>
112+
/// Mimics SQL Server error 10711: a <c>MERGE</c> statement specified an
113+
/// <c>INSERT</c> action in a <c>WHEN MATCHED</c> or <c>WHEN NOT MATCHED
114+
/// BY SOURCE</c> clause where only <c>UPDATE</c> / <c>DELETE</c> is
115+
/// legal. Probe-confirmed against SQL Server 2025 (2026-05-13): Class
116+
/// 15, State 1, exact wording verbatim. The <paramref name="clauseType"/>
117+
/// is either <c>"WHEN MATCHED"</c> or <c>"WHEN NOT MATCHED BY SOURCE"</c>.
118+
/// </summary>
119+
internal static SimulatedSqlException MergeInsertNotAllowedInClause(string clauseType) =>
120+
new($"An action of type 'INSERT' is not allowed in the '{clauseType}' clause of a MERGE statement.", 10711, 15, 1);
121+
122+
/// <summary>
123+
/// Mimics SQL Server error 10714: a <c>MERGE</c> statement had more than
124+
/// one <c>WHEN NOT MATCHED [BY TARGET]</c> clause. Real SQL Server allows
125+
/// at most one INSERT branch (unlike <c>WHEN MATCHED</c> and <c>WHEN
126+
/// NOT MATCHED BY SOURCE</c>, both of which can have multiple
127+
/// AND-conditioned clauses). Probe-confirmed against SQL Server 2025
128+
/// (2026-05-13): Class 15, State 1, exact wording verbatim — note the
129+
/// idiosyncratic <c>"a 'INSERT' clause"</c> phrasing.
130+
/// </summary>
131+
internal static SimulatedSqlException MergeMultipleNotMatchedClauses() =>
132+
new("An action of type 'WHEN NOT MATCHED' cannot appear more than once in a 'INSERT' clause of a MERGE statement.", 10714, 15, 1);
133+
75134
/// <summary>
76135
/// Mimics SQL Server error 10713: a <c>MERGE</c> statement was not
77136
/// followed by a <c>;</c>. Probe-confirmed verbatim text (note the

SqlServerSimulator/Parser/ContextualKeyword.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ enum ContextualKeyword
6666
Schemabinding,
6767
Scoped,
6868
Sequence,
69+
Target,
6970
SetError,
71+
Source,
7072
Start,
7173
Throw,
7274
Time,

SqlServerSimulator/Parser/Tokenizer.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ static class Tokenizer
4747
'/' => ParseForwardSlashOrComment(command, ref index),
4848
'[' => ParseBracketDelimitedString(command, ref index),
4949
'+' or '*' or '%' or '(' or ')' or ',' or '.' or ';' or '=' or '&' or '|' or '^' or '>' or '<' or '!' => new Operator(command, index++),
50+
'$' when IsDollarAction(command, index) => ParseDollarAction(command, ref index),
5051
'$' or '¢' or '£' or '¥' or '฿' or (>= '₠' and <= '₱') => ParseCurrencyLiteral(command, ref index),
5152
var c => throw SimulatedSqlException.SyntaxErrorNear(c) // Might throw on valid-but-unsupported syntax.
5253
};
@@ -280,6 +281,43 @@ private static Literal ParseHexLiteral(string command, ref int index)
280281
private static bool IsHexDigit(char c) =>
281282
c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F');
282283

284+
/// <summary>
285+
/// Returns true when the cursor sits on a <c>$action</c> pseudo-column
286+
/// — only recognized in MERGE's OUTPUT clause, but tokenized here so
287+
/// it surfaces as a single <see cref="UnquotedString"/> with value
288+
/// <c>"$action"</c> rather than tokenizing into a money-literal
289+
/// <c>$</c> followed by an unrelated identifier. Word-boundary
290+
/// terminated (rejects <c>$action_</c>).
291+
/// </summary>
292+
private static bool IsDollarAction(string command, int index)
293+
{
294+
if (index + 6 >= command.Length)
295+
return false;
296+
var body = command.AsSpan(index + 1, 6);
297+
if (!body.Equals("action", StringComparison.OrdinalIgnoreCase))
298+
return false;
299+
if (index + 7 < command.Length)
300+
{
301+
var next = command[index + 7];
302+
if (char.IsLetterOrDigit(next) || next == '_')
303+
return false;
304+
}
305+
return true;
306+
}
307+
308+
/// <summary>
309+
/// Emits the <c>$action</c> pseudo-column as a single
310+
/// <see cref="UnquotedString"/>. The MERGE OUTPUT parser detects it
311+
/// by string comparison and synthesizes a <c>MergeActionReference</c>
312+
/// expression.
313+
/// </summary>
314+
private static UnquotedString ParseDollarAction(string command, ref int index)
315+
{
316+
var start = index;
317+
index += 7;
318+
return (UnquotedString)UnquotedString.CheckReserved(command, start, 7);
319+
}
320+
283321
/// <summary>
284322
/// Parses a money literal: a single currency symbol followed by an
285323
/// optional sign, optional digits, and an optional fractional part.

0 commit comments

Comments
 (0)