Skip to content

Commit 12d8e83

Browse files
committed
Implemented MERGE INTO <view>.
1 parent 0be6a28 commit 12d8e83

3 files changed

Lines changed: 538 additions & 73 deletions

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**: `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.
187+
- **MERGE gaps**: CTE-precedes-MERGE bare-name form (`WITH cte AS (…) MERGE … USING cte ON …`) and `MERGE INTO <updatable view>` both ship; the latter has full parity with the UPDATE / INSERT / DELETE-through-view shapes — visibility filter scopes the target row set, `WITH CHECK OPTION` enforced on inserts / updates (Msg 550), view-column rename / reorder translates through `View.BaseColumnOrdinals`, INSTEAD OF INSERT / UPDATE / DELETE triggers replace the heap-write path per action. `MERGE … OUTPUT` through a view raises `NotSupportedException` (matches existing pattern; view-column projection through `INSERTED` / `DELETED` deferred). 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.
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: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// MERGE INTO updatable view — full parity with the
7+
/// UPDATE / INSERT / DELETE-through-view shapes: view-column lookups
8+
/// translate through <c>BaseColumnOrdinals</c>, visibility filters scope
9+
/// the target row set, <c>WITH CHECK OPTION</c> is enforced on
10+
/// inserts/updates, and INSTEAD OF triggers replace the heap-write path
11+
/// per action. Probed against real SQL Server 2025 (2026-05-19).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class MergeViewTests
15+
{
16+
[TestMethod]
17+
public void SimpleView_NotMatchedInsert_LandsInBase()
18+
{
19+
var sim = new Simulation();
20+
sim.ExecuteBatches(
21+
"create table base_t (id int primary key, v int)",
22+
"create view v_base as select id, v from base_t",
23+
"""
24+
merge into v_base using (values(1,10),(2,20)) src(id, v) on v_base.id = src.id
25+
when not matched then insert (id, v) values (src.id, src.v);
26+
""");
27+
AreEqual(2, sim.ExecuteScalar("select count(*) from base_t"));
28+
AreEqual(10, sim.ExecuteScalar("select v from base_t where id = 1"));
29+
}
30+
31+
[TestMethod]
32+
public void SimpleView_MatchedUpdate_LandsInBase()
33+
{
34+
var sim = new Simulation();
35+
sim.ExecuteBatches(
36+
"create table base_t (id int primary key, v int); insert base_t values (1, 0)",
37+
"create view v_base as select id, v from base_t",
38+
"""
39+
merge into v_base using (values(1, 99)) src(id, v) on v_base.id = src.id
40+
when matched then update set v = src.v;
41+
""");
42+
AreEqual(99, sim.ExecuteScalar("select v from base_t where id = 1"));
43+
}
44+
45+
[TestMethod]
46+
public void SimpleView_NotMatchedBySourceDelete_RemovesBaseRows()
47+
{
48+
var sim = new Simulation();
49+
sim.ExecuteBatches(
50+
"create table base_t (id int primary key, v int); insert base_t values (1,1),(2,2),(3,3)",
51+
"create view v_base as select id, v from base_t",
52+
"""
53+
merge into v_base using (values(1,11)) src(id, v) on v_base.id = src.id
54+
when matched then update set v = src.v
55+
when not matched by source then delete;
56+
""");
57+
AreEqual(1, sim.ExecuteScalar("select count(*) from base_t"));
58+
AreEqual(11, sim.ExecuteScalar("select v from base_t where id = 1"));
59+
}
60+
61+
[TestMethod]
62+
public void RenamedColumns_ViewColumnNamesAreUsedInMerge()
63+
{
64+
var sim = new Simulation();
65+
sim.ExecuteBatches(
66+
"create table base_t (id int primary key, v int)",
67+
"create view v_renamed as select id as pk, v as score from base_t",
68+
"""
69+
merge into v_renamed using (values(1, 100), (2, 200)) src(pk, score) on v_renamed.pk = src.pk
70+
when not matched then insert (pk, score) values (src.pk, src.score);
71+
""");
72+
AreEqual(100, sim.ExecuteScalar("select v from base_t where id = 1"));
73+
AreEqual(200, sim.ExecuteScalar("select v from base_t where id = 2"));
74+
}
75+
76+
[TestMethod]
77+
public void ReorderedColumns_BaseColumnOrdinalsTranslate()
78+
{
79+
var sim = new Simulation();
80+
sim.ExecuteBatches(
81+
"create table base_t (id int primary key, v int, label varchar(20))",
82+
"create view v_reordered as select label, id, v from base_t",
83+
"""
84+
merge into v_reordered using (values('foo', 1, 10)) src(label, id, v) on v_reordered.id = src.id
85+
when not matched then insert (label, id, v) values (src.label, src.id, src.v);
86+
""");
87+
AreEqual("foo", sim.ExecuteScalar("select label from base_t where id = 1"));
88+
AreEqual(10, sim.ExecuteScalar("select v from base_t where id = 1"));
89+
}
90+
91+
[TestMethod]
92+
public void VisibilityFilter_HidesNonMatchingTargetRows()
93+
{
94+
// Base row id=2 has active=0, so the view filter excludes it. The
95+
// MERGE source has both id=1 and id=2; id=2 is invisible to the view
96+
// so the NMBT branch triggers an INSERT — which conflicts with the
97+
// hidden row's PK, raising Msg 2627.
98+
var sim = new Simulation();
99+
sim.ExecuteBatches(
100+
"create table base_t (id int primary key, v int, active bit); insert base_t values (1, 100, 1), (2, 200, 0)",
101+
"create view v_active as select id, v from base_t where active = 1");
102+
var ex = sim.AssertSqlError("""
103+
merge into v_active using (values(1, 11), (2, 22)) src(id, v) on v_active.id = src.id
104+
when matched then update set v = src.v
105+
when not matched then insert (id, v) values (src.id, src.v);
106+
""", 2627);
107+
Assert.Contains("PRIMARY KEY", ex.Message);
108+
}
109+
110+
[TestMethod]
111+
public void VisibilityFilter_OnlyVisibleRowsParticipateInMatched()
112+
{
113+
// Same setup, but a source that only hits the visible row → no
114+
// conflict; UPDATE applies to id=1 and leaves id=2 untouched.
115+
var sim = new Simulation();
116+
sim.ExecuteBatches(
117+
"create table base_t (id int primary key, v int, active bit); insert base_t values (1, 100, 1), (2, 200, 0)",
118+
"create view v_active as select id, v from base_t where active = 1",
119+
"""
120+
merge into v_active using (values(1, 11)) src(id, v) on v_active.id = src.id
121+
when matched then update set v = src.v;
122+
""");
123+
AreEqual(11, sim.ExecuteScalar("select v from base_t where id = 1"));
124+
AreEqual(200, sim.ExecuteScalar("select v from base_t where id = 2"));
125+
}
126+
127+
[TestMethod]
128+
public void CheckOption_InsertViolation_RaisesMsg550()
129+
{
130+
// CHECK OPTION on v_pos requires v > 0; INSERT of v = -5 violates.
131+
var sim = new Simulation();
132+
sim.ExecuteBatches(
133+
"create table base_t (id int primary key, v int)",
134+
"create view v_pos as select id, v from base_t where v > 0 with check option");
135+
_ = sim.AssertSqlError("""
136+
merge into v_pos using (values(1, -5)) src(id, v) on v_pos.id = src.id
137+
when not matched then insert (id, v) values (src.id, src.v);
138+
""", 550);
139+
}
140+
141+
[TestMethod]
142+
public void CheckOption_UpdateViolation_RaisesMsg550()
143+
{
144+
var sim = new Simulation();
145+
sim.ExecuteBatches(
146+
"create table base_t (id int primary key, v int); insert base_t values (1, 10)",
147+
"create view v_pos as select id, v from base_t where v > 0 with check option");
148+
_ = sim.AssertSqlError("""
149+
merge into v_pos using (values(1, -1)) src(id, v) on v_pos.id = src.id
150+
when matched then update set v = src.v;
151+
""", 550);
152+
}
153+
154+
[TestMethod]
155+
public void NonUpdatableView_RaisesMsg4405()
156+
{
157+
// Multi-base view (join) — not updatable.
158+
var sim = new Simulation();
159+
sim.ExecuteBatches(
160+
"create table a (id int primary key, v int); create table b (id int primary key, v int)",
161+
"create view v_join as select a.id, a.v as av, b.v as bv from a join b on a.id = b.id");
162+
_ = sim.AssertSqlError("""
163+
merge into v_join using (values(1, 1, 1)) src(id, av, bv) on v_join.id = src.id
164+
when not matched then insert (id, av, bv) values (src.id, src.av, src.bv);
165+
""", 4405);
166+
}
167+
168+
[TestMethod]
169+
public void InsteadOfInsertTrigger_FiresInsteadOfHeapWrite()
170+
{
171+
// INSTEAD OF INSERT routes through the trigger; the base table sees
172+
// no writes from the MERGE itself — only what the trigger body does.
173+
var sim = new Simulation();
174+
sim.ExecuteBatches(
175+
"create table base_t (id int primary key, v int)",
176+
"create table log_t (id int identity, msg varchar(50))",
177+
"create view v_base as select id, v from base_t",
178+
"create trigger tr_v_ins on v_base instead of insert as begin insert into log_t (msg) values ('fired'); insert into base_t select * from inserted; end",
179+
"""
180+
merge into v_base using (values(1, 10)) src(id, v) on v_base.id = src.id
181+
when not matched then insert (id, v) values (src.id, src.v);
182+
""");
183+
AreEqual(1, sim.ExecuteScalar("select count(*) from log_t"));
184+
AreEqual(1, sim.ExecuteScalar("select count(*) from base_t"));
185+
AreEqual(10, sim.ExecuteScalar("select v from base_t where id = 1"));
186+
}
187+
188+
[TestMethod]
189+
public void InsteadOfUpdateTrigger_FiresInsteadOfHeapWrite()
190+
{
191+
// INSTEAD OF UPDATE: the heap row stays put unless the trigger
192+
// explicitly mutates it; the trigger gets INSERTED/DELETED in
193+
// view shape.
194+
var sim = new Simulation();
195+
sim.ExecuteBatches(
196+
"create table base_t (id int primary key, v int); insert base_t values (1, 0)",
197+
"create table log_t (id int identity, before_v int, after_v int)",
198+
"create view v_base as select id, v from base_t",
199+
"create trigger tr_v_upd on v_base instead of update as insert into log_t (before_v, after_v) select d.v, i.v from inserted i join deleted d on i.id = d.id",
200+
"""
201+
merge into v_base using (values(1, 99)) src(id, v) on v_base.id = src.id
202+
when matched then update set v = src.v;
203+
""");
204+
AreEqual(1, sim.ExecuteScalar("select count(*) from log_t"));
205+
AreEqual(0, sim.ExecuteScalar("select before_v from log_t"));
206+
AreEqual(99, sim.ExecuteScalar("select after_v from log_t"));
207+
AreEqual(0, sim.ExecuteScalar("select v from base_t where id = 1"));
208+
}
209+
210+
[TestMethod]
211+
public void InsteadOfDeleteTrigger_FiresInsteadOfHeapWrite()
212+
{
213+
var sim = new Simulation();
214+
sim.ExecuteBatches(
215+
"create table base_t (id int primary key, v int); insert base_t values (1, 10)",
216+
"create table log_t (id int identity, msg varchar(50))",
217+
"create view v_base as select id, v from base_t",
218+
"create trigger tr_v_del on v_base instead of delete as insert into log_t (msg) values ('deleted')",
219+
"""
220+
merge into v_base using (values(1, 99)) src(id, v) on v_base.id = src.id
221+
when matched then delete;
222+
""");
223+
AreEqual(1, sim.ExecuteScalar("select count(*) from log_t"));
224+
AreEqual(1, sim.ExecuteScalar("select count(*) from base_t"));
225+
}
226+
227+
[TestMethod]
228+
public void DerivedColumn_WriteRejected_Msg4406()
229+
{
230+
// View projects a computed column; writes to it raise Msg 4406.
231+
var sim = new Simulation();
232+
sim.ExecuteBatches(
233+
"create table base_t (id int primary key, v int)",
234+
"create view v_derived as select id, v, v * 2 as doubled from base_t");
235+
_ = sim.AssertSqlError("""
236+
merge into v_derived using (values(1, 10, 20)) src(id, v, doubled) on v_derived.id = src.id
237+
when not matched then insert (id, v, doubled) values (src.id, src.v, src.doubled);
238+
""", 4406);
239+
}
240+
241+
[TestMethod]
242+
public void DerivedColumn_OmittedFromInsert_Works()
243+
{
244+
// Same view, but the INSERT only writes the non-derived columns.
245+
var sim = new Simulation();
246+
sim.ExecuteBatches(
247+
"create table base_t (id int primary key, v int)",
248+
"create view v_derived as select id, v, v * 2 as doubled from base_t",
249+
"""
250+
merge into v_derived using (values(1, 7)) src(id, v) on v_derived.id = src.id
251+
when not matched then insert (id, v) values (src.id, src.v);
252+
""");
253+
AreEqual(7, sim.ExecuteScalar("select v from base_t where id = 1"));
254+
AreEqual(14, sim.ExecuteScalar("select doubled from v_derived where id = 1"));
255+
}
256+
257+
[TestMethod]
258+
public void OutputThroughView_RaisesNotSupported()
259+
{
260+
var sim = new Simulation();
261+
sim.ExecuteBatches(
262+
"create table base_t (id int primary key, v int)",
263+
"create view v_base as select id, v from base_t");
264+
_ = Throws<System.NotSupportedException>(() => sim.ExecuteNonQuery("""
265+
merge into v_base using (values(1, 10)) src(id, v) on v_base.id = src.id
266+
when not matched then insert (id, v) values (src.id, src.v) output inserted.id;
267+
"""));
268+
}
269+
270+
[TestMethod]
271+
public void ViewWithAlias_Works()
272+
{
273+
var sim = new Simulation();
274+
sim.ExecuteBatches(
275+
"create table base_t (id int primary key, v int)",
276+
"create view v_base as select id, v from base_t",
277+
"""
278+
merge into v_base as v using (values(1, 10)) src(id, v) on v.id = src.id
279+
when not matched then insert (id, v) values (src.id, src.v);
280+
""");
281+
AreEqual(10, sim.ExecuteScalar("select v from base_t where id = 1"));
282+
}
283+
}

0 commit comments

Comments
 (0)