Skip to content

Commit 401df3b

Browse files
committed
Linked servers are now simulated by linking separate Simulation instances.
1 parent 12d8e83 commit 401df3b

13 files changed

Lines changed: 895 additions & 2 deletions

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Auto-loaded orientation. `README.md` is for humans.
44

55
## Identity
66

7-
`SqlServerSimulator`: in-process .NET 10 SQL Server simulation. It's an **ADO.NET stand-in for `Microsoft.Data.SqlClient`** — consumers create a `Simulation`, get a `SimulatedDbConnection` via `CreateDbConnection()`, and use it with (for example) `Microsoft.EntityFrameworkCore.SqlServer` instead of going through SqlClient over the wire. Public surface is `Simulation` + `CreateDbConnection()` + `SimulatedDbConnection` (subscribers for `InfoMessage` + supporting `SimulatedInfoMessageEventArgs` / `SimulatedError` / `SimulatedErrorCollection`); `QualityTests.PublicApiWhitelist` fails the build if anything else leaks public — resist expanding.
7+
`SqlServerSimulator`: in-process .NET 10 SQL Server simulation. It's an **ADO.NET stand-in for `Microsoft.Data.SqlClient`** — consumers create a `Simulation`, get a `SimulatedDbConnection` via `CreateDbConnection()`, and use it with (for example) `Microsoft.EntityFrameworkCore.SqlServer` instead of going through SqlClient over the wire. Public surface is intentionally minimal so internals stay free to refactor; `QualityTests.PublicApiWhitelist` is the authoritative list and fails the build on any unintended expansion — resist adding to it.
88

99
`SqlServerSimulator.EFCore` is a sibling package whose only public method is `UseSqlServerSimulator(DbContextOptionsBuilder, DbConnection)`. EF Core's SqlServer provider keeps emitting SQL-Server-flavored SQL; the adapter just registers an `IRelationalTypeMappingSourcePlugin` for the (CLR, store) pairs whose default mappings downcast to `SqlParameter` (since the simulator's connection isn't a `SqlConnection`).
1010

@@ -172,13 +172,15 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
172172
- **`ALTER DATABASE SET <option>` accept-list + `COLLATE` clause**[`database-options.md`](docs/claude/database-options.md).
173173
- **New top-level statement parser or dispatch-loop separator rules**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
174174
- **BACPAC import** (`Simulation.ImportBacpac` instance method — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
175+
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part-name FROM routing through the remote's ADO.NET pipeline, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
175176

176177
## Not modeled
177178

178179
- **Key-range locks** — sole remaining phase 4+ deferral. See [`locking.md`](docs/claude/locking.md) for what does ship (full 8-mode matrix, SNAPSHOT/RCSI, DMVs, Msg 1205/1222/3952/3960).
179180
- `BEGIN DISTRIBUTED TRANSACTION` raises `NotSupportedException` at dispatch. `BEGIN TRANSACTION <name> WITH MARK 'm'` raises **Msg 319** at parse (the parser doesn't accept `WITH` here); bare named transactions (`BEGIN TRAN t1`) ship.
180181
- **`CREATE DATABASE`** / **`CREATE ASSEMBLY`** — Msg 102 at parse. Adding databases routes through `Simulation.ImportBacpac`; CLR assemblies aren't modeled at all.
181-
- **Cross-database DML** (`INSERT`/`UPDATE`/`DELETE`/`MERGE` through a 3-part name targeting a different database) raises `NotSupportedException` via `BatchContext.RejectCrossDatabaseMutation` — trigger scope swapping, identity allocation routing, undo-log scoping, FK validation across DB boundaries pending. Issue `USE <db>` and reference via a 1- or 2-part name. Cross-database SELECT / JOIN / catalog-view reads (`SELECT * FROM other.dbo.t`, `dbA.sys.tables`) ship. **Four-part `linkedserver.db.schema.t` names** surface as Msg 208 instead of real SQL Server's Msg 7202 (no linked-server model). See [`schemas.md`](docs/claude/schemas.md).
182+
- **Cross-database DML** (`INSERT`/`UPDATE`/`DELETE`/`MERGE` through a 3-part name targeting a different database) raises `NotSupportedException` via `BatchContext.RejectCrossDatabaseMutation` — trigger scope swapping, identity allocation routing, undo-log scoping, FK validation across DB boundaries pending. Issue `USE <db>` and reference via a 1- or 2-part name. Cross-database SELECT / JOIN / catalog-view reads (`SELECT * FROM other.dbo.t`, `dbA.sys.tables`) ship. **Four-part-name writes** (`linkedserver.db.schema.t` INSERT/UPDATE/DELETE/MERGE) also raise via the same factory — see linked-servers below. See [`schemas.md`](docs/claude/schemas.md).
183+
- **Cross-server writes** through four-part names raise `NotSupportedException` (lock-manager + undo-log coordination across `Simulation` boundaries pending; parallels the `BEGIN DISTRIBUTED TRANSACTION` stance). **Four-part-name reads** ship through the remote `Simulation`'s full ADO.NET pipeline. See [`linked-servers.md`](docs/claude/linked-servers.md). **Catalog views through four-part names** (`srv.db.sys.tables`) currently fall through to Msg 208 — issue catalog queries against the remote `Simulation` directly.
182184
- **`SET <option>` accept-list** (`Simulation.Set.cs`) covers XACT_ABORT, all ANSI/session-state toggles, `STATISTICS {IO|TIME|XML|PROFILE}`, value-taking options (`TEXTSIZE`/`DATEFIRST`/etc.) — all parse-and-discard. Unknown SET → Msg 195. `SET @v`, `IDENTITY_INSERT`, `NOCOUNT`, `LOCK_TIMEOUT`, `TRANSACTION ISOLATION LEVEL` carry semantic effect.
183185
- **`ALTER DATABASE … SET` / `COLLATE` surface** — see [`database-options.md`](docs/claude/database-options.md). Most options parse-and-discard; `COMPATIBILITY_LEVEL`, `ALLOW_SNAPSHOT_ISOLATION`, `READ_COMMITTED_SNAPSHOT` are load-bearing.
184186
- `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.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
[TestClass]
6+
public class LinkedServerTests
7+
{
8+
/// <summary>
9+
/// Two-step registration: <c>AddRemoteSimulation</c> binds the name, but
10+
/// the linked server isn't reachable from SQL text until
11+
/// <c>sp_addlinkedserver</c> activates it. A four-part reference before
12+
/// activation surfaces as Msg 208 (matches the simulator's existing
13+
/// behavior for unknown linked servers — Msg 7202 isn't ported).
14+
/// </summary>
15+
[TestMethod]
16+
public void FourPartName_BeforeSpAddLinkedServer_Msg208()
17+
{
18+
var remote = new Simulation();
19+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key, val int not null); insert remote_t values (1, 10), (2, 20)");
20+
21+
var local = new Simulation();
22+
local.AddRemoteSimulation("OTHER", remote);
23+
24+
_ = local.AssertSqlError("select val from OTHER.simulated.dbo.remote_t where id = 1", 208);
25+
}
26+
27+
[TestMethod]
28+
public void Select_RoutesToRemoteSimulation()
29+
{
30+
var remote = new Simulation();
31+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key, val int not null); insert remote_t values (1, 10), (2, 20)");
32+
33+
var local = new Simulation();
34+
local.AddRemoteSimulation("OTHER", remote);
35+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver @server = 'OTHER', @srvproduct = 'SQL Server'");
36+
37+
AreEqual(20, local.ExecuteScalar("select val from OTHER.simulated.dbo.remote_t where id = 2"));
38+
}
39+
40+
/// <summary>
41+
/// Positional sp_addlinkedserver form: real BACPAC scripts emit
42+
/// <c>EXEC sp_addlinkedserver 'OTHER', 'SQL Server'</c>. The simulator
43+
/// accepts both forms.
44+
/// </summary>
45+
[TestMethod]
46+
public void SpAddLinkedServer_Positional_Activates()
47+
{
48+
var remote = new Simulation();
49+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key); insert remote_t values (1), (2), (3)");
50+
51+
var local = new Simulation();
52+
local.AddRemoteSimulation("OTHER", remote);
53+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER', 'SQL Server'");
54+
55+
AreEqual(3, local.ExecuteScalar("select count(*) from OTHER.simulated.dbo.remote_t"));
56+
}
57+
58+
[TestMethod]
59+
public void Join_AcrossLinkedServer()
60+
{
61+
var remote = new Simulation();
62+
_ = remote.ExecuteNonQuery("""
63+
create table dbo.parts (part_id int not null primary key, name varchar(20) not null);
64+
insert parts values (1, 'widget'), (2, 'gadget'), (3, 'gizmo')
65+
""");
66+
67+
var local = new Simulation();
68+
_ = local.ExecuteNonQuery("create table dbo.orders (order_id int not null primary key, part_id int not null, qty int not null); insert orders values (1, 1, 5), (2, 2, 10), (3, 1, 7)");
69+
local.AddRemoteSimulation("PARTSRV", remote);
70+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'PARTSRV', 'SQL Server'");
71+
72+
var qty = local.ExecuteScalar("""
73+
select sum(o.qty)
74+
from dbo.orders o
75+
inner join PARTSRV.simulated.dbo.parts p on p.part_id = o.part_id
76+
where p.name = 'widget'
77+
""");
78+
AreEqual(12, qty);
79+
}
80+
81+
[TestMethod]
82+
public void Insert_ThroughFourPartName_Rejected()
83+
{
84+
var remote = new Simulation();
85+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key)");
86+
87+
var local = new Simulation();
88+
local.AddRemoteSimulation("OTHER", remote);
89+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
90+
91+
var ex = Throws<NotSupportedException>(() => local.ExecuteNonQuery("insert OTHER.simulated.dbo.remote_t values (99)"));
92+
Contains("Cross-server write", ex.Message);
93+
}
94+
95+
[TestMethod]
96+
public void Update_ThroughFourPartName_Rejected()
97+
{
98+
var remote = new Simulation();
99+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key, v int not null); insert remote_t values (1, 1)");
100+
101+
var local = new Simulation();
102+
local.AddRemoteSimulation("OTHER", remote);
103+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
104+
105+
_ = Throws<NotSupportedException>(() => local.ExecuteNonQuery("update OTHER.simulated.dbo.remote_t set v = 2 where id = 1"));
106+
}
107+
108+
[TestMethod]
109+
public void Delete_ThroughFourPartName_Rejected()
110+
{
111+
var remote = new Simulation();
112+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key); insert remote_t values (1)");
113+
114+
var local = new Simulation();
115+
local.AddRemoteSimulation("OTHER", remote);
116+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
117+
118+
_ = Throws<NotSupportedException>(() => local.ExecuteNonQuery("delete from OTHER.simulated.dbo.remote_t where id = 1"));
119+
}
120+
121+
[TestMethod]
122+
public void SpAddLinkedServer_Unregistered_NotSupported()
123+
{
124+
var local = new Simulation();
125+
var ex = Throws<NotSupportedException>(() => local.ExecuteNonQuery("exec sp_addlinkedserver 'NOT_REGISTERED'"));
126+
Contains("AddRemoteSimulation", ex.Message);
127+
}
128+
129+
[TestMethod]
130+
public void SpDropServer_RemovesLinkedServer()
131+
{
132+
var remote = new Simulation();
133+
_ = remote.ExecuteNonQuery("create table dbo.t (id int not null primary key); insert t values (1)");
134+
135+
var local = new Simulation();
136+
local.AddRemoteSimulation("OTHER", remote);
137+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
138+
AreEqual(1, local.ExecuteScalar("select count(*) from OTHER.simulated.dbo.t"));
139+
140+
_ = local.ExecuteNonQuery("exec sp_dropserver 'OTHER'");
141+
142+
_ = local.AssertSqlError("select count(*) from OTHER.simulated.dbo.t", 208);
143+
}
144+
145+
[TestMethod]
146+
public void SpDropServer_Missing_Msg15015()
147+
{
148+
var local = new Simulation();
149+
local.AssertSqlError("exec sp_dropserver 'NOT_REGISTERED'", 15015,
150+
"The server 'NOT_REGISTERED' does not exist. Use sp_helpserver to show available servers.");
151+
}
152+
153+
[TestMethod]
154+
public void SpServerOption_ParseAndDiscard()
155+
{
156+
var remote = new Simulation();
157+
_ = remote.ExecuteNonQuery("create table dbo.t (id int not null primary key); insert t values (1)");
158+
159+
var local = new Simulation();
160+
local.AddRemoteSimulation("OTHER", remote);
161+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
162+
_ = local.ExecuteNonQuery("exec sp_serveroption @server = 'OTHER', @optname = 'data access', @optvalue = 'TRUE'");
163+
_ = local.ExecuteNonQuery("exec sp_addlinkedsrvlogin 'OTHER', 'false', NULL, 'sa', 'password'");
164+
165+
AreEqual(1, local.ExecuteScalar("select count(*) from OTHER.simulated.dbo.t"));
166+
}
167+
168+
/// <summary>
169+
/// The remote SELECT must run through the remote's full pipeline — its
170+
/// catalog views resolve at the remote, not against the local
171+
/// Simulation's catalog. Verified by reading sys.tables on the remote
172+
/// via a four-part-name reference; if the routing accidentally bound
173+
/// against the local instance, the row count would differ.
174+
/// </summary>
175+
[TestMethod]
176+
public void RemoteSelect_RunsThroughRemotePipeline()
177+
{
178+
var remote = new Simulation();
179+
_ = remote.ExecuteNonQuery("create table dbo.a (id int); create table dbo.b (id int); create table dbo.c (id int)");
180+
181+
var local = new Simulation();
182+
_ = local.ExecuteNonQuery("create table dbo.only_local (id int)");
183+
local.AddRemoteSimulation("OTHER", remote);
184+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
185+
186+
// Verify remote-side data is what arrives, not local-side.
187+
AreEqual(3, remote.ExecuteScalar("select count(*) from sys.tables"));
188+
AreEqual(1, local.ExecuteScalar("select count(*) from sys.tables"));
189+
}
190+
191+
/// <summary>
192+
/// Self-linkage: a simulation can register itself as a linked server.
193+
/// Useful for tests that want to exercise the round-trip code without
194+
/// constructing a second Simulation.
195+
/// </summary>
196+
[TestMethod]
197+
public void Selflink_RoundTrip()
198+
{
199+
var sim = new Simulation();
200+
_ = sim.ExecuteNonQuery("create table dbo.t (id int not null primary key); insert t values (42)");
201+
sim.AddRemoteSimulation("SELF", sim);
202+
_ = sim.ExecuteNonQuery("exec sp_addlinkedserver 'SELF'");
203+
204+
AreEqual(42, sim.ExecuteScalar("select id from SELF.simulated.dbo.t"));
205+
}
206+
207+
[TestMethod]
208+
public void SysServers_LocalOnly()
209+
{
210+
var sim = new Simulation();
211+
AreEqual(1, sim.ExecuteScalar("select count(*) from sys.servers"));
212+
AreEqual("SIMULATED", sim.ExecuteScalar("select name from sys.servers where is_linked = 0"));
213+
}
214+
215+
[TestMethod]
216+
public void SysServers_ProjectsActiveLinkedServers()
217+
{
218+
var remote = new Simulation();
219+
var local = new Simulation();
220+
local.AddRemoteSimulation("OTHER", remote);
221+
local.AddRemoteSimulation("THIRD", new Simulation());
222+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver @server = 'OTHER', @srvproduct = 'My Product', @provider = 'My Provider', @datasrc = 'My Source'");
223+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'THIRD', 'SQL Server'");
224+
225+
AreEqual(3, local.ExecuteScalar("select count(*) from sys.servers"));
226+
AreEqual(2, local.ExecuteScalar("select count(*) from sys.servers where is_linked = 1"));
227+
AreEqual("My Product", local.ExecuteScalar("select product from sys.servers where name = 'OTHER'"));
228+
AreEqual("My Provider", local.ExecuteScalar("select provider from sys.servers where name = 'OTHER'"));
229+
AreEqual("My Source", local.ExecuteScalar("select data_source from sys.servers where name = 'OTHER'"));
230+
}
231+
232+
[TestMethod]
233+
public void FourPartName_ToRemoteCatalogView_FallsThroughToMsg208()
234+
{
235+
var remote = new Simulation();
236+
_ = remote.ExecuteNonQuery("create table dbo.t (id int)");
237+
238+
var local = new Simulation();
239+
local.AddRemoteSimulation("OTHER", remote);
240+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
241+
242+
// Catalog views aren't reachable through four-part names yet; the
243+
// lookup misses because sys.tables isn't a HeapTable in the remote's
244+
// schema dict. Falls through to Msg 208 — documented gap.
245+
_ = local.AssertSqlError("select count(*) from OTHER.simulated.sys.tables", 208);
246+
}
247+
248+
[TestMethod]
249+
public void FourPartName_MissingRemoteTable_Msg208()
250+
{
251+
var remote = new Simulation();
252+
var local = new Simulation();
253+
local.AddRemoteSimulation("OTHER", remote);
254+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
255+
256+
_ = local.AssertSqlError("select * from OTHER.simulated.dbo.no_such_table", 208);
257+
}
258+
259+
[TestMethod]
260+
public void Merge_ThroughFourPartName_Rejected()
261+
{
262+
var remote = new Simulation();
263+
_ = remote.ExecuteNonQuery("create table dbo.remote_t (id int not null primary key)");
264+
265+
var local = new Simulation();
266+
_ = local.ExecuteNonQuery("create table dbo.src (id int not null)");
267+
local.AddRemoteSimulation("OTHER", remote);
268+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
269+
270+
_ = Throws<NotSupportedException>(() => local.ExecuteNonQuery("merge OTHER.simulated.dbo.remote_t as t using dbo.src as s on s.id = t.id when matched then delete;"));
271+
}
272+
273+
/// <summary>
274+
/// Reactivating an existing linked-server name replaces the prior
275+
/// binding silently — matches real SQL Server's <c>sp_addlinkedserver</c>
276+
/// idempotency. Useful for BACPAC scripts that re-emit registration
277+
/// on every import.
278+
/// </summary>
279+
[TestMethod]
280+
public void SpAddLinkedServer_Idempotent()
281+
{
282+
var remoteA = new Simulation();
283+
_ = remoteA.ExecuteNonQuery("create table dbo.t (id int); insert t values (1)");
284+
var remoteB = new Simulation();
285+
_ = remoteB.ExecuteNonQuery("create table dbo.t (id int); insert t values (2), (3)");
286+
287+
var local = new Simulation();
288+
local.AddRemoteSimulation("X", remoteA);
289+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'X'");
290+
AreEqual(1, local.ExecuteScalar("select count(*) from X.simulated.dbo.t"));
291+
292+
local.AddRemoteSimulation("X", remoteB);
293+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'X'");
294+
AreEqual(2, local.ExecuteScalar("select count(*) from X.simulated.dbo.t"));
295+
}
296+
297+
[TestMethod]
298+
public void SysServers_RemovedBySpDropServer()
299+
{
300+
var remote = new Simulation();
301+
var local = new Simulation();
302+
local.AddRemoteSimulation("OTHER", remote);
303+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'OTHER'");
304+
AreEqual(2, local.ExecuteScalar("select count(*) from sys.servers"));
305+
306+
_ = local.ExecuteNonQuery("exec sp_dropserver 'OTHER'");
307+
AreEqual(1, local.ExecuteScalar("select count(*) from sys.servers"));
308+
}
309+
}

SqlServerSimulator.Tests/QualityTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public void PublicApiWhitelist()
4242
".ctor",
4343
nameof(Simulation.CreateDbConnection),
4444
nameof(Simulation.ImportBacpac),
45+
nameof(Simulation.AddRemoteSimulation),
4546
];
4647

4748
Assert.HasCount(allowedMemberNames.Count, memberNames);

0 commit comments

Comments
 (0)