Skip to content

Commit 0be6a28

Browse files
committed
MERGE can now use a CTE as a source.
1 parent 333bbdc commit 0be6a28

3 files changed

Lines changed: 160 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
184184
- `RANGE BETWEEN <N> PRECEDING/FOLLOWING` numeric-offset — Msg 4194, matching real SQL Server's licensed-feature rejection. `ROWS` numeric-offset ships. Default frame with ORDER BY is `RANGE UNBOUNDED PRECEDING TO CURRENT ROW`; without it, whole partition. LAST_VALUE matches real SQL Server's default-frame semantic.
185185
- Recursive-part feature restrictions (Msg 460 / 461 / 462 / 467 / 465) — silently accepted with possibly-incorrect semantics. Apps that exercise these hit rejection on real SQL Server too.
186186
- `LEN(ntext)` raising Msg 8116; legacy `READTEXT` / `WRITETEXT` / `UPDATETEXT`.
187-
- **MERGE gaps**: source as a CTE-headed SELECT (`USING (WITH cte AS …)`), MERGE inside a CTE body, multi-statement WHEN-clause bodies, `MERGE INTO <view>`.
187+
- **MERGE gaps**: `MERGE INTO <view>` — confirmed against real SQL Server 2025 as a real feature (single-base updatable view, source via `VALUES` / SELECT / bare-table all work). Three related shapes initially flagged as gaps turned out to be invalid on real SQL Server too (Msg 156 at parse): `USING (WITH cte AS …)`, `MERGE` inside a CTE body, and multi-statement `WHEN` bodies. CTE-precedes-MERGE bare-name form (`WITH cte AS (…) MERGE … USING cte ON …`) ships.
188188
- `UNIQUE` on a *non-persisted* computed column (PK/UNIQUE on `PERSISTED` ships). Msg 4936 determinism gate for PERSISTED computed columns also not enforced.
189189
- Heap allocation tracking (flat page list, no IAM/PFS).
190190
- **Table-variable named constraints / foreign keys** — Msg 102 (matches real SQL Server's grammar restriction inside `DECLARE @t TABLE`). Multi-variable DECLARE with a table variable, mixed scalar+table DECLARE, and `SET IDENTITY_INSERT @t ON` also reject. Column features (IDENTITY / UNIQUE / inline + table-level CHECK / computed / rowversion) all ship — see [`table-variables.md`](docs/claude/table-variables.md).
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for <c>WITH cte AS (…) MERGE INTO … USING cte …</c> — the
7+
/// CTE-precedes-MERGE shape where the bare CTE name is the source. Real SQL
8+
/// Server (probe-confirmed 2026-05-19) accepts the bare-name form with or
9+
/// without an alias; rejects the related but invalid shape
10+
/// <c>MERGE … USING (WITH cte AS …)</c> at parse with Msg 156 (the CTE
11+
/// can't live inside the <c>USING (…)</c> parens — already enforced as
12+
/// Msg 102 by the simulator's grammar).
13+
/// </summary>
14+
[TestClass]
15+
public sealed class MergeCteTests
16+
{
17+
[TestMethod]
18+
public void CtePrecedingMerge_BareName_InsertsThroughCte()
19+
{
20+
var sim = new Simulation();
21+
_ = sim.ExecuteNonQuery("""
22+
create table tgt (id int primary key, v int);
23+
create table src (id int, v int);
24+
insert src values (1, 10), (2, 20);
25+
with c as (select id, v from src)
26+
merge into tgt using c on tgt.id = c.id
27+
when matched then update set v = c.v
28+
when not matched then insert (id, v) values (c.id, c.v);
29+
""");
30+
AreEqual(2, sim.ExecuteScalar("select count(*) from tgt"));
31+
AreEqual(10, sim.ExecuteScalar("select v from tgt where id = 1"));
32+
AreEqual(20, sim.ExecuteScalar("select v from tgt where id = 2"));
33+
}
34+
35+
[TestMethod]
36+
public void CtePrecedingMerge_BareNameWithAlias_Works()
37+
{
38+
var sim = new Simulation();
39+
_ = sim.ExecuteNonQuery("""
40+
create table tgt (id int primary key, v int);
41+
create table src (id int, v int);
42+
insert src values (1, 10);
43+
with c as (select id, v from src)
44+
merge into tgt using c as s on tgt.id = s.id
45+
when not matched then insert (id, v) values (s.id, s.v);
46+
""");
47+
AreEqual(10, sim.ExecuteScalar("select v from tgt where id = 1"));
48+
}
49+
50+
[TestMethod]
51+
public void CtePrecedingMerge_UpdateMatched_Works()
52+
{
53+
// Mixed insert + update via CTE source.
54+
var sim = new Simulation();
55+
_ = sim.ExecuteNonQuery("""
56+
create table tgt (id int primary key, v int);
57+
insert tgt values (1, 0);
58+
create table src (id int, v int);
59+
insert src values (1, 100), (2, 200);
60+
with c as (select id, v from src)
61+
merge into tgt using c on tgt.id = c.id
62+
when matched then update set v = c.v
63+
when not matched then insert (id, v) values (c.id, c.v);
64+
""");
65+
AreEqual(100, sim.ExecuteScalar("select v from tgt where id = 1"));
66+
AreEqual(200, sim.ExecuteScalar("select v from tgt where id = 2"));
67+
}
68+
69+
[TestMethod]
70+
public void CtePrecedingMerge_CteWithComputedColumns_PreservesProjection()
71+
{
72+
// CTE projects a computed value; MERGE references it via the CTE's
73+
// column name.
74+
var sim = new Simulation();
75+
_ = sim.ExecuteNonQuery("""
76+
create table tgt (id int primary key, doubled int);
77+
create table src (id int, v int);
78+
insert src values (1, 5), (2, 7);
79+
with c as (select id, v * 2 as doubled from src)
80+
merge into tgt using c on tgt.id = c.id
81+
when not matched then insert (id, doubled) values (c.id, c.doubled);
82+
""");
83+
AreEqual(10, sim.ExecuteScalar("select doubled from tgt where id = 1"));
84+
AreEqual(14, sim.ExecuteScalar("select doubled from tgt where id = 2"));
85+
}
86+
87+
[TestMethod]
88+
public void CteInsideUsingParens_StillRejected()
89+
{
90+
// Phantom-feature shape — real SQL Server raises Msg 156 here, the
91+
// simulator raises Msg 102. Same end state (rejected); shape
92+
// documented as not supported on real SQL Server either.
93+
_ = new Simulation().AssertSqlError("""
94+
create table tgt (id int primary key, v int);
95+
create table src (id int, v int);
96+
merge into tgt using (with c as (select id, v from src) select * from c) s on tgt.id = s.id
97+
when not matched then insert (id, v) values (s.id, s.v);
98+
""", 102);
99+
}
100+
101+
[TestMethod]
102+
public void CtePrecedingMerge_ShadowsRealTableOfSameName()
103+
{
104+
// Real table `c` with different shape exists; the CTE binding takes
105+
// precedence in the MERGE source resolution (same shadowing semantic
106+
// SELECT has via Selection.cs's CTE-first lookup).
107+
var sim = new Simulation();
108+
_ = sim.ExecuteNonQuery("""
109+
create table tgt (id int primary key, v int);
110+
create table c (other_col varchar(50));
111+
insert c values ('should not appear');
112+
with c as (select 1 as id, 10 as v)
113+
merge into tgt using c on tgt.id = c.id
114+
when not matched then insert (id, v) values (c.id, c.v);
115+
""");
116+
AreEqual(10, sim.ExecuteScalar("select v from tgt where id = 1"));
117+
}
118+
119+
[TestMethod]
120+
public void SubqueryReferencingCte_Works_BaselineFromAuditDontRegress()
121+
{
122+
// B2 from the audit probe — this already worked before the B1 fix;
123+
// pin it so a regression there surfaces clearly.
124+
var sim = new Simulation();
125+
_ = sim.ExecuteNonQuery("""
126+
create table tgt (id int primary key, v int);
127+
create table src (id int, v int);
128+
insert src values (1, 10);
129+
with c as (select id, v from src)
130+
merge into tgt using (select id, v from c) s on tgt.id = s.id
131+
when not matched then insert (id, v) values (s.id, s.v);
132+
""");
133+
AreEqual(10, sim.ExecuteScalar("select v from tgt where id = 1"));
134+
}
135+
}

SqlServerSimulator/Simulation/Simulation.Merge.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,30 @@ private static (Func<BatchContext, List<SqlValue[]>> Materialize, string Alias,
295295
SqlType[] sourceSchema;
296296
string[] columnNames;
297297

298+
// CTE binding shadows table / view resolution: `WITH c AS (…) MERGE …
299+
// USING c ON …` references the CTE, not any same-named base object.
300+
// Only 1-part names route here; CTEs aren't schema-qualifiable.
301+
if (objectName.Count == 1
302+
&& context.CteBindings is { } cteBindings
303+
&& cteBindings.TryGetValue(objectName.Leaf, out var cteBinding))
304+
{
305+
if (cteBinding.Plan is null)
306+
throw SimulatedSqlException.RecursiveCteMissingUnionAll(cteBinding.Name);
307+
var ctePlan = cteBinding.Plan;
308+
sourceSchema = ctePlan.Schema;
309+
columnNames = cteBinding.ColumnNames;
310+
var cteAlias = Selection.ConsumeOptionalAlias(context) ?? cteBinding.Name;
311+
materialize = batch =>
312+
{
313+
var rs = ctePlan.Execute(batch);
314+
var rows = new List<SqlValue[]>();
315+
foreach (var rowBytes in rs.RowBytes)
316+
rows.Add(RowDecoder.DecodeRow(sourceSchema.AsSpan(), rowBytes));
317+
return rows;
318+
};
319+
return (materialize, cteAlias, columnNames, sourceSchema);
320+
}
321+
298322
if (context.Batch.TryResolveView(objectName, out var resolvedView))
299323
{
300324
sourceSchema = new SqlType[resolvedView.OutputColumns.Length];

0 commit comments

Comments
 (0)