|
| 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