Skip to content

Commit 92e5671

Browse files
committed
Skipped statements compile-then-skip like real SQL Server: a missing base object in an un-taken branch parses to completion via placeholder FROM-source/call metadata and is discarded whole.
1 parent 1b67a7d commit 92e5671

12 files changed

Lines changed: 410 additions & 40 deletions

File tree

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dotnet_diagnostic.IDE0022.severity = none # Use block body for method
1616
dotnet_diagnostic.IDE0023.severity = none # Use block body for conversion operator
1717
dotnet_diagnostic.IDE0024.severity = none # Use block body for operator
1818
dotnet_diagnostic.IDE0040.severity = none # Add accessibility modifiers
19+
dotnet_diagnostic.IDE0045.severity = none # Forced ternary operators can be too unreadable.
1920
dotnet_diagnostic.IDE0061.severity = none # Use block body for local function
2021
dotnet_diagnostic.IDE0072.severity = none # Add missing cases
2122

SqlServerSimulator.Tests.SqlClient/BatchErrorRecoveryTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,42 @@ public async Task DacFxShape_MissingObjectMidBatch_ExactlyOneErrorToken_LaterSta
9393
AreEqual(208, errors[0]);
9494
}
9595

96+
/// <summary>
97+
/// The SSMS Query Store shape over the wire: a missing table behind an
98+
/// EXISTS inside an un-taken outer IF, whose inner IF carries an ELSE.
99+
/// Before skip-mode placeholder parse-continuation, the missing table threw
100+
/// mid-parse and the recovery scan orphaned the inner ELSE into a bare
101+
/// statement — the continue-on-error wire path turned that into a runaway
102+
/// error stream (the SSMS Query Store probe crash of 2026-07-15). The
103+
/// statement now parses to completion and is discarded, so the batch streams
104+
/// only the trailing SELECT with no error token.
105+
/// </summary>
106+
[TestMethod]
107+
public async Task SkippedBranch_ExistsMissingTableWithInnerElse_StreamsCleanly()
108+
{
109+
var simulation = new Simulation();
110+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
111+
112+
var errors = new List<int>();
113+
await using var connection = new SqlConnection(Wire.ConnectionString(listener));
114+
connection.FireInfoMessageEventOnUserErrors = true;
115+
connection.InfoMessage += (_, e) =>
116+
{
117+
foreach (SqlError error in e.Errors)
118+
errors.Add(error.Number);
119+
};
120+
await connection.OpenAsync(TestContext.CancellationToken);
121+
122+
await using var command = new SqlCommand(
123+
"if 1 = 0 begin if exists(select * from missing) select 1 as r else select 2 as r end "
124+
+ "select 'after' as r", connection);
125+
await using var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken);
126+
IsTrue(await reader.ReadAsync(TestContext.CancellationToken));
127+
AreEqual("after", reader.GetString(0));
128+
IsFalse(await reader.NextResultAsync(TestContext.CancellationToken));
129+
IsEmpty(errors);
130+
}
131+
96132
[TestMethod]
97133
public async Task UseHintUnknownName_SurfacesMsg10715()
98134
{

SqlServerSimulator.Tests/BatchErrorRecoveryTests.cs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,27 @@ public void SkipModeBranch_ToleratesMissingObject_Unchanged()
9696
select 7
9797
"""));
9898

99+
/// <summary>
100+
/// A missing column on a <em>resolvable</em> table aborts the batch even
101+
/// from an un-taken IF branch. Probe-confirmed (SQL Server 2025,
102+
/// 2026-07-17): real SQL Server binds the columns of an existing table at
103+
/// compile time and raises Msg 207 regardless of the branch being dead, so
104+
/// the statement after the IF never runs. Deferred name resolution applies
105+
/// only when the base object is itself missing (see
106+
/// <see cref="SkipModeBranch_ToleratesMissingObject_Unchanged"/>) — a
107+
/// resolvable table's columns bind eagerly.
108+
/// </summary>
99109
[TestMethod]
100-
public void SkipModeBranch_ToleratesMissingColumn_Unchanged()
101-
=> AreEqual(7, new Simulation().ExecuteScalar("""
102-
create table t (id int);
103-
if 1 = 0 select no_such_col from t;
104-
select 7
105-
"""));
110+
public void SkipModeBranch_MissingColumnOnResolvableTable_AbortsBatch()
111+
{
112+
using var connection = new Simulation().CreateOpenConnection();
113+
_ = connection.CreateCommand("create table t (id int); create table marker (n int)").ExecuteNonQuery();
114+
using var failing = connection.CreateCommand(
115+
"insert marker values (1); if 1 = 0 select no_such_col from t; insert marker values (2)");
116+
var ex = Throws<SimulatedSqlException>(() => failing.ExecuteNonQuery());
117+
AreEqual(207, ex.Number);
118+
AreEqual(1, connection.CreateCommand("select count(*) from marker").ExecuteScalar());
119+
}
106120

107121
[TestMethod]
108122
public void ContinuableError_DoesNotAbort_FollowingRuns()

SqlServerSimulator.Tests/SkipModeNameResolutionTests.cs

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ namespace SqlServerSimulator;
44

55
/// <summary>
66
/// Deferred name resolution in skipped control-flow branches. Real SQL Server
7-
/// binds object / column names lazily, so an un-taken IF / WHILE branch (or a
8-
/// block skipped after BREAK / CONTINUE / RETURN) that references a
9-
/// nonexistent table / column compiles fine and is discarded. The simulator
10-
/// resolves names inline with parsing, so it swallows the Msg 208 / Msg 207
11-
/// that would otherwise surface — but only while dispatching in skip mode.
12-
/// A taken branch still raises, and syntax / structural errors in skipped
13-
/// branches still raise (only name resolution defers).
7+
/// binds base object names lazily, so an un-taken IF / WHILE branch (or a block
8+
/// skipped after BREAK / CONTINUE / RETURN) that references a nonexistent table
9+
/// / view / function compiles fine and is discarded. The simulator resolves
10+
/// names inline with parsing, so a skip-mode FROM / function-call miss
11+
/// substitutes placeholder metadata and the statement parses to completion (no
12+
/// Msg 208 / 4121). Deferral is scoped to the missing base object, though: a
13+
/// missing column on a <em>resolvable</em> table binds eagerly and raises
14+
/// Msg 207 even in a dead branch (probe-confirmed against SQL Server 2025), and
15+
/// a taken branch still raises. Syntax / structural errors in skipped branches
16+
/// still raise (only name resolution of a missing object defers).
1417
/// </summary>
1518
[TestClass]
1619
public sealed class SkipModeNameResolutionTests
@@ -29,12 +32,19 @@ public void IfFalse_UnknownTable_NoBlock()
2932
public void IfTrue_UnknownTable_StillRaises208()
3033
=> new Simulation().AssertSqlError("if 1 = 1 select * from nosuchtable", 208);
3134

35+
/// <summary>
36+
/// A missing column on a <em>resolvable</em> table is not deferred — real
37+
/// SQL Server binds an existing table's columns at compile time and raises
38+
/// Msg 207 even from an un-taken branch (probe-confirmed SQL Server 2025,
39+
/// 2026-07-17). Contrast <see cref="IfFalse_UnknownTable_NoBlock"/>, where
40+
/// the missing base object defers.
41+
/// </summary>
3242
[TestMethod]
33-
public void IfFalse_UnknownColumnInKnownTable_Tolerated()
43+
public void IfFalse_UnknownColumnInKnownTable_StillRaises207()
3444
{
3545
var sim = new Simulation();
3646
_ = sim.ExecuteNonQuery("create table t (id int)");
37-
AreEqual("ok", sim.ExecuteScalar("if 1 = 0 begin select bad_col from t end select 'ok'"));
47+
_ = sim.AssertSqlError("if 1 = 0 begin select bad_col from t end select 'ok'", 207);
3848
}
3949

4050
[TestMethod]
@@ -136,4 +146,136 @@ public void TableVariableDeclaredInSkippedBranch_UsableAfter()
136146
[TestMethod]
137147
public void SetUndeclaredVariableInSkippedBranch_StillRaises137()
138148
=> new Simulation().AssertSqlError("if 1 = 0 begin set @never = 1 end select 'ok'", 137);
149+
150+
// ---- Placeholder parse-continuation (probe matrix, 2026-07-17) ----
151+
152+
/// <summary>
153+
/// A missing schema-qualified scalar function in an un-taken branch defers
154+
/// (probe-confirmed: real SQL Server binds user functions lazily too, unlike
155+
/// a bare 1-part call which is a compile-time Msg 195). The call parses to
156+
/// completion and is discarded.
157+
/// </summary>
158+
[TestMethod]
159+
public void IfFalse_UnknownQualifiedFunction_Tolerated()
160+
=> AreEqual("ok", new Simulation().ExecuteScalar(
161+
"if 1 = 0 select dbo.no_such_fn(1, 2) select 'ok'"));
162+
163+
/// <summary>
164+
/// A bare (1-part) unresolved function is NOT deferred — real SQL Server
165+
/// treats it as a missing built-in and raises Msg 195 at compile time, even
166+
/// in a dead branch.
167+
/// </summary>
168+
[TestMethod]
169+
public void IfFalse_UnknownBareFunction_StillRaises195()
170+
=> new Simulation().AssertSqlError("if 1 = 0 select no_such_fn(1) select 'ok'", 195);
171+
172+
/// <summary>
173+
/// The un-taken THEN branch defers, so its trailing ELSE runs — the missing
174+
/// function must not orphan the ELSE into a spurious Msg 102.
175+
/// </summary>
176+
[TestMethod]
177+
public void IfFalse_UnknownFunctionThenElse_ElseRuns()
178+
=> AreEqual("else", new Simulation().ExecuteScalar(
179+
"if 1 = 0 select dbo.no_such_fn(1) as r else select 'else' as r"));
180+
181+
/// <summary>
182+
/// The SSMS Query Store / server-properties shape: a missing table behind an
183+
/// EXISTS in an un-taken outer branch. Regression for the orphaned-fragment
184+
/// cascade — the recovery scan used to abandon this mid-parse and re-dispatch
185+
/// the inner branch as a bare statement (spurious Msg 102 / 156).
186+
/// </summary>
187+
[TestMethod]
188+
public void SkippedOuter_ExistsMissingTableWithInnerElse_Tolerated()
189+
=> AreEqual("after", new Simulation().ExecuteScalar("""
190+
if 1 = 0 begin if exists(select * from missing) select 1 as r else select 2 as r end
191+
select 'after' as r
192+
"""));
193+
194+
/// <summary>
195+
/// The same EXISTS-behind-a-missing-table shape at top level: the un-taken
196+
/// THEN's inner IF/ELSE parses to completion and the following statement
197+
/// runs.
198+
/// </summary>
199+
[TestMethod]
200+
public void SkippedIf_ExistsMissingTableWithElse_Tolerated()
201+
=> AreEqual("after", new Simulation().ExecuteScalar(
202+
"if 1 = 0 if exists(select * from missing) select 1 as r else select 2 as r select 'after' as r"));
203+
204+
/// <summary>
205+
/// A missing table plus a second missing table inside a scalar subquery,
206+
/// both in the un-taken THEN — the whole statement defers, the ELSE runs.
207+
/// </summary>
208+
[TestMethod]
209+
public void SkippedIf_MissingTableWithMissingSubqueryTable_ElseRuns()
210+
=> AreEqual("else", new Simulation().ExecuteScalar(
211+
"if 1 = 0 select * from missing where a = (select b from other) else select 'else' as r"));
212+
213+
/// <summary>
214+
/// A missing table behind a CASE WHEN EXISTS in a skipped block, with the
215+
/// block's own ELSE — parses to completion and the ELSE runs.
216+
/// </summary>
217+
[TestMethod]
218+
public void SkippedBlock_CaseWhenExistsMissing_ElseRuns()
219+
=> AreEqual("else", new Simulation().ExecuteScalar("""
220+
if 1 = 0 begin select case when exists(select * from missing) then 1 else 2 end as r end
221+
else select 'else' as r
222+
"""));
223+
224+
/// <summary>
225+
/// A missing TVF invoked in the FROM clause of an un-taken branch defers
226+
/// (the argument list is parsed and discarded); the ELSE runs.
227+
/// </summary>
228+
[TestMethod]
229+
public void SkippedIf_MissingTvfInFrom_ElseRuns()
230+
=> AreEqual("else", new Simulation().ExecuteScalar(
231+
"if 1 = 0 select * from dbo.no_such_tvf(1, 2) else select 'else' as r"));
232+
233+
/// <summary>
234+
/// ORDER BY referencing a column of a missing table defers along with the
235+
/// table — no compile error in the dead branch.
236+
/// </summary>
237+
[TestMethod]
238+
public void SkippedIf_MissingTableOrderByMissingColumn_Tolerated()
239+
=> AreEqual("ok", new Simulation().ExecuteScalar(
240+
"if 1 = 0 select * from missing order by also_missing select 'ok'"));
241+
242+
/// <summary>
243+
/// ORDER BY referencing a missing column of a <em>resolvable</em> table is
244+
/// not deferred — Msg 207 fires even in the dead branch.
245+
/// </summary>
246+
[TestMethod]
247+
public void SkippedIf_KnownTableOrderByMissingColumn_StillRaises207()
248+
{
249+
var sim = new Simulation();
250+
_ = sim.ExecuteNonQuery("create table t (id int)");
251+
_ = sim.AssertSqlError("if 1 = 0 select id from t order by nope select 'ok'", 207);
252+
}
253+
254+
/// <summary>
255+
/// A missing table in one statement of a skipped block plus a missing column
256+
/// on a resolvable table in the next: real SQL Server binds the resolvable
257+
/// table's columns eagerly, so Msg 207 wins and aborts the batch — the
258+
/// block's ELSE never runs.
259+
/// </summary>
260+
[TestMethod]
261+
public void SkippedBlock_MissingTableThenBadColumnOnRealTable_Raises207()
262+
{
263+
var sim = new Simulation();
264+
_ = sim.ExecuteNonQuery("create table t (id int)");
265+
_ = sim.AssertSqlError("""
266+
if 1 = 0 begin select * from missing; select bad_col from t end
267+
else select 'else' as r
268+
select 'after' as r
269+
""", 207);
270+
}
271+
272+
/// <summary>
273+
/// A qualified column reference against a placeholder (missing) table
274+
/// resolves leniently, so the whole SELECT parses to completion and the ELSE
275+
/// runs.
276+
/// </summary>
277+
[TestMethod]
278+
public void SkippedIf_QualifiedColumnsOnMissingTable_ElseRuns()
279+
=> AreEqual("else", new Simulation().ExecuteScalar(
280+
"if 1 = 0 select m.foo, m.bar from missing m else select 'else' as r"));
139281
}

SqlServerSimulator/Parser/Expression.cs

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,38 @@ public static Expression Parse(ParserContext context)
298298
// probe-confirmed real SQL Server treats a table-valued
299299
// function used in scalar position as "missing scalar
300300
// UDF or ambiguous" rather than a distinct error.
301-
expression = reference.ReferencedName.Count >= 2
302-
? VarbinaryToHex.TryResolve(reference.ReferencedName, context) is { } systemFunction
303-
? systemFunction
304-
: context.Batch.TryResolveFunction(reference.ReferencedName, out var function) && function is ScalarFunction scalarFn
305-
? UserFunctionCall.ParseCall(scalarFn, context)
306-
: throw SimulatedSqlException.CannotFindUserDefinedFunction(reference.ReferencedName)
307-
: ResolveBuiltIn(reference.Name, context);
301+
if (reference.ReferencedName.Count >= 2)
302+
{
303+
if (VarbinaryToHex.TryResolve(reference.ReferencedName, context) is { } systemFunction)
304+
{
305+
expression = systemFunction;
306+
}
307+
else if (context.Batch.TryResolveFunction(reference.ReferencedName, out var function) && function is ScalarFunction scalarFn)
308+
{
309+
expression = UserFunctionCall.ParseCall(scalarFn, context);
310+
}
311+
else if (context.Batch.IsSkipping)
312+
{
313+
// Skip mode: real SQL Server defers user-function
314+
// binding, so an un-taken branch calling a missing
315+
// schema-qualified function compiles and is
316+
// discarded. Parse-and-discard the argument list so
317+
// the statement (and any trailing ELSE / END) parses
318+
// to completion instead of throwing Msg 4121 mid-
319+
// expression. A bare 1-part unrecognized function
320+
// still raises Msg 195 below — real SQL Server errors
321+
// on that at compile time even in a dead branch.
322+
expression = ParseDeferredCallAndDiscard(context);
323+
}
324+
else
325+
{
326+
throw SimulatedSqlException.CannotFindUserDefinedFunction(reference.ReferencedName);
327+
}
328+
}
329+
else
330+
{
331+
expression = ResolveBuiltIn(reference.Name, context);
332+
}
308333
// ResolveBuiltIn / ParseCall leave context.Token at the
309334
// closing ). The next loop iteration's GetNextOptional
310335
// advances past it; advancing here would skip an extra token.
@@ -362,6 +387,33 @@ public static Expression Parse(ParserContext context)
362387
}
363388
}
364389

390+
/// <summary>
391+
/// Skip-mode fallback for a schema-qualified function call whose name
392+
/// doesn't resolve. Parses and discards the comma-separated argument list
393+
/// so the cursor advances past the call, then returns a placeholder NULL
394+
/// expression. On entry the cursor is on the token after the opening
395+
/// <c>(</c>; on return it sits on the closing <c>)</c>, matching
396+
/// <see cref="UserFunctionCall.ParseFunctionArguments"/>'s post-condition so
397+
/// the outer parse loop resumes cleanly. Never runs outside skip mode — the
398+
/// discarded statement is conceptually never bound.
399+
/// </summary>
400+
private static Value ParseDeferredCallAndDiscard(ParserContext context)
401+
{
402+
if (context.Token is not Operator { Character: ')' })
403+
{
404+
while (true)
405+
{
406+
_ = Parse(context);
407+
if (context.Token is Operator { Character: ')' })
408+
break;
409+
if (context.Token is not Operator { Character: ',' })
410+
throw SimulatedSqlException.SyntaxErrorNear(context);
411+
context.MoveNextRequired();
412+
}
413+
}
414+
return new Value();
415+
}
416+
365417
/// <summary>
366418
/// Consumes a <c>WITHIN GROUP (ORDER BY expr [ASC|DESC] [, ...])</c>
367419
/// postfix and attaches the parsed items to <paramref name="aggregate"/>.

0 commit comments

Comments
 (0)