Skip to content

Commit 784fb51

Browse files
committed
Add OPENQUERY(linked_server, 'query'): pass-through ad-hoc rowset over the linked-server seam, with compile-time schema discovery, first-result-set-only semantics, and the probe-confirmed parse-validation / Msg 7202 error surface.
Record the corrected scope of the leaked-connection cleanup backlog item.
1 parent 45ef75f commit 784fb51

7 files changed

Lines changed: 390 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
177177
- **Per-column / per-expression collation, coercibility precedence, Msg 468 / 457 cross-collation enforcement, recognized catalog, `#temp` collation inheritance**[`collations.md`](docs/claude/collations.md).
178178
- **New top-level statement parser or dispatch-loop separator rules**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
179179
- **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).
180-
- **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).
180+
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part-name FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server, 'query')` ad-hoc pass-through with compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
181181

182182
## Not modeled
183183

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
[TestClass]
6+
public class OpenQueryTests
7+
{
8+
private static Simulation LocalWithRemote(out Simulation remote, string remoteSetup)
9+
{
10+
remote = new Simulation();
11+
_ = remote.ExecuteNonQuery(remoteSetup);
12+
13+
var local = new Simulation();
14+
local.AddRemoteSimulation("RMT", remote);
15+
_ = local.ExecuteNonQuery("exec sp_addlinkedserver 'RMT', 'SQL Server'");
16+
return local;
17+
}
18+
19+
[TestMethod]
20+
public void Basic_ReturnsRemoteRows()
21+
{
22+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key, name varchar(20) not null); insert t values (1, 'a'), (2, 'b')");
23+
AreEqual("b", local.ExecuteScalar("select name from OPENQUERY(RMT, 'select id, name from dbo.t where id = 2')"));
24+
}
25+
26+
[TestMethod]
27+
public void Basic_CountAllRows()
28+
{
29+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (1), (2), (3)");
30+
AreEqual(3, local.ExecuteScalar("select count(*) from OPENQUERY(RMT, 'select id from dbo.t')"));
31+
}
32+
33+
/// <summary>
34+
/// The pass-through query is arbitrary T-SQL run on the remote, not just a
35+
/// table name: WHERE / expressions / aggregation all execute remotely.
36+
/// </summary>
37+
[TestMethod]
38+
public void Passthrough_AggregationRunsRemotely()
39+
{
40+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key, qty int not null); insert t values (1, 5), (2, 10), (3, 7)");
41+
AreEqual(22, local.ExecuteScalar("select total from OPENQUERY(RMT, 'select sum(qty) as total from dbo.t')"));
42+
}
43+
44+
[TestMethod]
45+
public void Passthrough_ExpressionColumn()
46+
{
47+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (10)");
48+
AreEqual(20, local.ExecuteScalar("select doubled from OPENQUERY(RMT, 'select id * 2 as doubled from dbo.t')"));
49+
}
50+
51+
/// <summary>
52+
/// OPENQUERY returns only the FIRST result set of a multi-statement
53+
/// pass-through batch.
54+
/// </summary>
55+
[TestMethod]
56+
public void FirstResultSetOnly()
57+
{
58+
var local = LocalWithRemote(out _, "create table dbo.a (v int not null); insert a values (100); create table dbo.b (v int not null); insert b values (200), (300)");
59+
// First SELECT yields one row (100); the second SELECT is ignored.
60+
AreEqual(1, local.ExecuteScalar("select count(*) from OPENQUERY(RMT, 'select v from dbo.a; select v from dbo.b')"));
61+
AreEqual(100, local.ExecuteScalar("select v from OPENQUERY(RMT, 'select v from dbo.a; select v from dbo.b')"));
62+
}
63+
64+
[TestMethod]
65+
public void Position_InJoin()
66+
{
67+
var local = LocalWithRemote(out _, "create table dbo.parts (part_id int not null primary key, name varchar(20) not null); insert parts values (1, 'widget'), (2, 'gadget')");
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+
70+
var qty = local.ExecuteScalar("""
71+
select sum(o.qty)
72+
from dbo.orders o
73+
inner join OPENQUERY(RMT, 'select part_id, name from dbo.parts') p on p.part_id = o.part_id
74+
where p.name = 'widget'
75+
""");
76+
AreEqual(12, qty);
77+
}
78+
79+
[TestMethod]
80+
public void Position_InDerivedTable()
81+
{
82+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key, v int not null); insert t values (1, 10), (2, 20), (3, 30)");
83+
AreEqual(50, local.ExecuteScalar("select sum(v) from (select v from OPENQUERY(RMT, 'select id, v from dbo.t where id >= 2')) d"));
84+
}
85+
86+
[TestMethod]
87+
public void Alias_WithoutAs()
88+
{
89+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (7)");
90+
AreEqual(7, local.ExecuteScalar("select q.id from OPENQUERY(RMT, 'select id from dbo.t') q"));
91+
}
92+
93+
[TestMethod]
94+
public void Alias_WithAs()
95+
{
96+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (7)");
97+
AreEqual(7, local.ExecuteScalar("select q.id from OPENQUERY(RMT, 'select id from dbo.t') as q"));
98+
}
99+
100+
[TestMethod]
101+
public void KeywordIsCaseInsensitive()
102+
{
103+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (5)");
104+
AreEqual(5, local.ExecuteScalar("select id from openquery(RMT, 'select id from dbo.t')"));
105+
}
106+
107+
[TestMethod]
108+
public void BracketedServerName_Resolves()
109+
{
110+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (9)");
111+
AreEqual(9, local.ExecuteScalar("select id from OPENQUERY([RMT], 'select id from dbo.t')"));
112+
}
113+
114+
/// <summary>
115+
/// A pass-through payload that produces no result set (empty string, a
116+
/// non-SELECT statement) surfaces a clear <see cref="NotSupportedException"/>
117+
/// naming the condition — the exact real-server Msg isn't probed.
118+
/// </summary>
119+
[TestMethod]
120+
public void EmptyQuery_NoResultSet_NotSupported()
121+
{
122+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
123+
var ex = Throws<NotSupportedException>(() => local.ExecuteScalar("select * from OPENQUERY(RMT, '')"));
124+
Contains("no result set", ex.Message);
125+
}
126+
127+
[TestMethod]
128+
public void NonSelectQuery_NoResultSet_NotSupported()
129+
{
130+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
131+
var ex = Throws<NotSupportedException>(() => local.ExecuteScalar("select * from OPENQUERY(RMT, 'declare @x int = 5')"));
132+
Contains("no result set", ex.Message);
133+
}
134+
135+
[TestMethod]
136+
public void UnknownServer_Msg7202()
137+
{
138+
var local = new Simulation();
139+
var ex = local.AssertSqlError("select * from OPENQUERY(NOPE, 'select 1 as x')", 7202);
140+
Contains("Could not find server 'NOPE' in sys.servers", ex.Message);
141+
}
142+
143+
[TestMethod]
144+
public void VariableQueryArg_Msg102()
145+
{
146+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
147+
_ = local.AssertSqlError("declare @q varchar(100) = 'select id from dbo.t'; select * from OPENQUERY(RMT, @q)", 102);
148+
}
149+
150+
[TestMethod]
151+
public void StringLiteralServerArg_Msg102()
152+
{
153+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
154+
_ = local.AssertSqlError("select * from OPENQUERY('RMT', 'select id from dbo.t')", 102);
155+
}
156+
157+
[TestMethod]
158+
public void TooFewArgs_Msg102()
159+
{
160+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
161+
_ = local.AssertSqlError("select * from OPENQUERY(RMT)", 102);
162+
}
163+
164+
[TestMethod]
165+
public void TooManyArgs_Msg102()
166+
{
167+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
168+
_ = local.AssertSqlError("select * from OPENQUERY(RMT, 'select id from dbo.t', 'extra')", 102);
169+
}
170+
171+
[TestMethod]
172+
public void ConcatenatedQueryArg_Msg102()
173+
{
174+
var local = LocalWithRemote(out _, "create table dbo.t (id int)");
175+
_ = local.AssertSqlError("select * from OPENQUERY(RMT, 'select ' + 'id from dbo.t')", 102);
176+
}
177+
178+
/// <summary>
179+
/// A column-alias list on OPENQUERY is rejected (real SQL Server: Msg 102
180+
/// near the first alias identifier; the simulator raises Msg 102 near the
181+
/// opening <c>(</c>). Without the guard the general FROM parser tolerates
182+
/// and ignores the list, silently keeping the remote column names.
183+
/// </summary>
184+
[TestMethod]
185+
public void ColumnAliasList_Msg102()
186+
{
187+
var local = LocalWithRemote(out _, "create table dbo.t (id int not null primary key); insert t values (1)");
188+
_ = local.AssertSqlError("select c1 from OPENQUERY(RMT, 'select id from dbo.t') q(c1)", 102);
189+
}
190+
}

SqlServerSimulator/Errors/SimulatedSqlException.SchemaErrors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ internal static SimulatedSqlException InvalidCompatibilityLevel() =>
2727

2828
internal static SimulatedSqlException ThereIsAlreadyAnObject(string name) => new($"There is already an object named '{name}' in the database.", 2714, 16, 6);
2929

30+
/// <summary>
31+
/// Mimics SQL Server error 7202: an <c>OPENQUERY</c> (or four-part name)
32+
/// references a linked-server name that isn't registered in
33+
/// <c>sys.servers</c>. Wording probe-confirmed against SQL Server 2025;
34+
/// Class 11 State 2.
35+
/// </summary>
36+
internal static SimulatedSqlException LinkedServerNotFound(string serverName) =>
37+
new($"Could not find server '{serverName}' in sys.servers. Verify that the correct server name was specified. If necessary, execute the stored procedure sp_addlinkedserver to add the server to sys.servers.", 7202, 11, 2);
38+
3039
/// <summary>
3140
/// Mimics SQL Server error 911: <c>USE &lt;db&gt;</c> targets a database
3241
/// that doesn't exist in this <see cref="Simulation"/>. Wording

SqlServerSimulator/Parser/Selection.LinkedServer.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Diagnostics.CodeAnalysis;
22
using System.Globalization;
3+
using SqlServerSimulator.Parser.Tokens;
34
using SqlServerSimulator.Storage;
45

56
namespace SqlServerSimulator.Parser;
@@ -83,4 +84,124 @@ private static List<byte[]> StreamRemoteRows(LinkedServer server, string databas
8384
}
8485

8586
private static string EscapeIdent(string ident) => ident.Replace("]", "]]", StringComparison.Ordinal);
87+
88+
/// <summary>
89+
/// Parses an <c>OPENQUERY(server, 'query')</c> FROM / JOIN source. Enters
90+
/// with <see cref="ParserContext.Token"/> on the <c>OPENQUERY</c> name;
91+
/// on return <see cref="ParserContext.Token"/> sits on the first token
92+
/// past the closing <c>)</c> (ready for the caller's in-place alias
93+
/// handling). Exactly two arguments: a bare identifier (plain or
94+
/// bracketed) naming the linked server, and a bare string literal
95+
/// carrying the pass-through query. Anything else (a literal / dotted
96+
/// name in the server slot, a variable / concatenation / extra arg in
97+
/// the query slot) fails as Msg 102 because the token after each slot
98+
/// isn't the expected separator. The linked server is resolved eagerly:
99+
/// an unregistered name raises Msg 7202. The pass-through query then
100+
/// runs once on the remote to discover its result-set schema (columns
101+
/// aren't known until the query executes).
102+
/// </summary>
103+
internal static Selection ParseOpenQuery(ParserContext context)
104+
{
105+
if (context.GetNextRequired() is not Operator { Character: '(' })
106+
throw SimulatedSqlException.SyntaxErrorNear(context);
107+
108+
if (context.GetNextRequired() is not Name serverToken)
109+
throw SimulatedSqlException.SyntaxErrorNear(context);
110+
var serverName = serverToken.Value;
111+
112+
if (context.GetNextRequired() is not Operator { Character: ',' })
113+
throw SimulatedSqlException.SyntaxErrorNear(context);
114+
115+
if (context.GetNextRequired() is not Literal queryLiteral || queryLiteral.Value.Type.Category != SqlTypeCategory.String)
116+
throw SimulatedSqlException.SyntaxErrorNear(context);
117+
var queryText = queryLiteral.Value.AsString;
118+
119+
if (context.GetNextRequired() is not Operator { Character: ')' })
120+
throw SimulatedSqlException.SyntaxErrorNear(context);
121+
context.MoveNextOptional();
122+
123+
// OPENQUERY reads external remote state, so its FROM-less-style
124+
// baked schema mustn't be cached across executions with a different
125+
// remote. Disqualify the batch from plan-cache promotion.
126+
context.Batch.HasSessionScopedReference = true;
127+
128+
if (!context.Batch.Connection.Simulation.ActiveLinkedServers.TryGetValue(serverName, out var server))
129+
throw SimulatedSqlException.LinkedServerNotFound(serverName);
130+
131+
var (schema, columnNames) = DiscoverOpenQuerySchema(server, queryText);
132+
return ForOpenQuery(server, queryText, schema, columnNames);
133+
}
134+
135+
/// <summary>
136+
/// Wraps an <c>OPENQUERY</c> pass-through query as a
137+
/// <see cref="Selection"/> usable as a <see cref="FromSource.LateralPlan"/>.
138+
/// Unlike the four-part-name <see cref="ForLinkedServer"/>, the schema is
139+
/// discovered once at parse time and passed in here; each
140+
/// <see cref="Execute"/> RE-RUNS the query on the remote and streams the
141+
/// first result set's rows (it does not cache the schema-discovery pass's
142+
/// rows). So a side-effecting pass-through payload runs once for schema
143+
/// discovery plus once per outer execution — acceptable for the SELECT
144+
/// payloads OPENQUERY targets.
145+
/// </summary>
146+
internal static Selection ForOpenQuery(LinkedServer server, string queryText, SqlType[] schema, string[] columnNames) =>
147+
new(
148+
schema,
149+
columnNames,
150+
hasOrderBy: false,
151+
hasTopOrOffsetOrFetch: false,
152+
rowSource: (_, _) => StreamOpenQueryRows(server, queryText));
153+
154+
/// <summary>
155+
/// Runs the pass-through query on the remote and captures the first
156+
/// result set's schema + column names (OPENQUERY returns only the first
157+
/// result set). A query that yields no result set (empty string, a
158+
/// non-SELECT statement) raises <see cref="NotSupportedException"/> — the
159+
/// exact real-server Msg for this case isn't probed, so the simulator
160+
/// names the condition rather than fabricating a number.
161+
/// </summary>
162+
[SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "OPENQUERY's second argument is a pass-through query string by design — the caller intends it to run verbatim on the remote. The remote command runs against a sibling Simulation in the same process; there's no external SQL surface to inject against.")]
163+
private static (SqlType[] Schema, string[] ColumnNames) DiscoverOpenQuerySchema(LinkedServer server, string queryText)
164+
{
165+
// An all-whitespace / empty pass-through string reaches the remote
166+
// command as an uninitialized CommandText (which raises its own
167+
// InvalidOperationException); short-circuit to the uniform no-result
168+
// message instead so every no-rowset payload surfaces the same way.
169+
if (string.IsNullOrWhiteSpace(queryText))
170+
throw OpenQueryNoResultSet(server.Name);
171+
172+
using var conn = server.Target.CreateDbConnection();
173+
conn.Open();
174+
using var cmd = conn.CreateCommand();
175+
cmd.CommandText = queryText;
176+
foreach (var outcome in server.Target.CreateResultSetsForCommand(cmd))
177+
{
178+
if (outcome is SimulatedSqlResultSet rs)
179+
return (rs.Schema, rs.ColumnNames);
180+
}
181+
throw OpenQueryNoResultSet(server.Name);
182+
}
183+
184+
private static NotSupportedException OpenQueryNoResultSet(string serverName) =>
185+
new($"OPENQUERY pass-through query on linked server '{serverName}' returned no result set. Only queries that produce a result set are supported.");
186+
187+
/// <summary>
188+
/// Re-runs the pass-through query on the remote and materializes the
189+
/// first result set's encoded rows (buffered before the remote
190+
/// connection disposes). Only the first result set is returned, matching
191+
/// OPENQUERY's semantics.
192+
/// </summary>
193+
[SuppressMessage("Security", "CA2100:Review SQL queries for security vulnerabilities", Justification = "OPENQUERY's second argument is a pass-through query string by design — the caller intends it to run verbatim on the remote. The remote command runs against a sibling Simulation in the same process; there's no external SQL surface to inject against.")]
194+
private static List<byte[]> StreamOpenQueryRows(LinkedServer server, string queryText)
195+
{
196+
using var conn = server.Target.CreateDbConnection();
197+
conn.Open();
198+
using var cmd = conn.CreateCommand();
199+
cmd.CommandText = queryText;
200+
foreach (var outcome in server.Target.CreateResultSetsForCommand(cmd))
201+
{
202+
if (outcome is SimulatedSqlResultSet rs)
203+
return [.. rs.RowBytes];
204+
}
205+
return [];
206+
}
86207
}

0 commit comments

Comments
 (0)