Skip to content

Commit f4f9c14

Browse files
committed
Unify the four drifted statement-boundary keyword sets into one IsStatementBoundary predicate so semicolon-less statement sequences parse for the full statement-keyword set.
1 parent 7950d2d commit f4f9c14

8 files changed

Lines changed: 235 additions & 54 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Semicolon-less statement sequences: SQL Server accepts one statement
7+
/// immediately followed by another with no separating <c>;</c>, and SSMS
8+
/// generates such batches. The simulator recognizes the second statement's
9+
/// leading keyword through a single shared predicate
10+
/// (<c>Simulation.IsStatementBoundary</c>) that every consumer — the dispatch
11+
/// loop, the SELECT projection-list terminator, the EXEC-argument scanner, and
12+
/// the principal-DDL parse-and-discard tail — routes through, so the full
13+
/// statement-keyword set (including <c>EXEC</c>/<c>EXECUTE</c>, <c>USE</c>,
14+
/// <c>GRANT</c>, <c>FETCH</c>, …) terminates the preceding statement uniformly.
15+
/// The cross-product below pairs each kind of preceding statement with each
16+
/// kind of following statement, joined by a newline only, and proves the batch
17+
/// parses and runs to the trailing sentinel <c>SELECT 42</c>.
18+
/// </summary>
19+
[TestClass]
20+
public sealed class StatementBoundaryTests
21+
{
22+
// Shared prelude gives every combination a table, a declared+opened
23+
// cursor, and an @rc slot so FETCH / GRANT / EXEC @rc all have targets.
24+
// The load-bearing junction is the newline-only join between the
25+
// preceding and following statements — no `;` separates them. The trailing
26+
// SELECT 42 sentinel is read from the LAST result set (a following such as
27+
// SELECT / FETCH emits its own result set first), proving the whole batch
28+
// parsed and ran to completion; pre-fix these threw Msg 102.
29+
private static int RunPair(string preceding, string following)
30+
{
31+
using var reader = new Simulation().ExecuteReader($"""
32+
create table t (id int not null);
33+
insert into t values (1);
34+
declare @rc int;
35+
declare c cursor for select id from t;
36+
open c;
37+
{preceding}
38+
{following};
39+
select 42
40+
""");
41+
object? last = null;
42+
do
43+
{
44+
while (reader.Read())
45+
last = reader[0];
46+
}
47+
while (reader.NextResult());
48+
return (int)last!;
49+
}
50+
51+
[TestMethod]
52+
[DataRow("select 1")]
53+
[DataRow("declare @x int")]
54+
[DataRow("if 1 = 1 select 1")]
55+
[DataRow("set nocount on")]
56+
[DataRow("insert into t values (2)")]
57+
public void FollowedByExecProc_NoSemicolon_Parses(string preceding)
58+
=> AreEqual(42, RunPair(preceding, "exec xp_qv N'a', N'b'"));
59+
60+
[TestMethod]
61+
[DataRow("select 1")]
62+
[DataRow("declare @x int")]
63+
[DataRow("if 1 = 1 select 1")]
64+
[DataRow("set nocount on")]
65+
[DataRow("insert into t values (2)")]
66+
public void FollowedByExecReturnCapture_NoSemicolon_Parses(string preceding)
67+
=> AreEqual(42, RunPair(preceding, "exec @rc = xp_qv N'a', N'b'"));
68+
69+
[TestMethod]
70+
[DataRow("select 1")]
71+
[DataRow("declare @x int")]
72+
[DataRow("if 1 = 1 select 1")]
73+
[DataRow("set nocount on")]
74+
[DataRow("insert into t values (2)")]
75+
public void FollowedByUse_NoSemicolon_Parses(string preceding)
76+
=> AreEqual(42, RunPair(preceding, "use master"));
77+
78+
[TestMethod]
79+
[DataRow("select 1")]
80+
[DataRow("declare @x int")]
81+
[DataRow("if 1 = 1 select 1")]
82+
[DataRow("set nocount on")]
83+
[DataRow("insert into t values (2)")]
84+
public void FollowedByGrant_NoSemicolon_Parses(string preceding)
85+
=> AreEqual(42, RunPair(preceding, "grant select on t to public"));
86+
87+
[TestMethod]
88+
[DataRow("select 1")]
89+
[DataRow("declare @x int")]
90+
[DataRow("if 1 = 1 select 1")]
91+
[DataRow("set nocount on")]
92+
[DataRow("insert into t values (2)")]
93+
public void FollowedByFetch_NoSemicolon_Parses(string preceding)
94+
=> AreEqual(42, RunPair(preceding, "fetch c"));
95+
96+
[TestMethod]
97+
[DataRow("select 1")]
98+
[DataRow("declare @x int")]
99+
[DataRow("if 1 = 1 select 1")]
100+
[DataRow("set nocount on")]
101+
[DataRow("insert into t values (2)")]
102+
public void FollowedBySelect_NoSemicolon_Parses(string preceding)
103+
=> AreEqual(42, RunPair(preceding, "select 1"));
104+
105+
// WITH after a SELECT projection stays the specific Msg 319 ("Incorrect
106+
// syntax near … expects a preceding statement terminated with a
107+
// semicolon") rather than being swallowed as a generic boundary — the
108+
// CTE-continuation case is checked ahead of the shared boundary predicate.
109+
[TestMethod]
110+
public void SelectFollowedByCte_WithoutSemicolon_RaisesMsg319()
111+
=> new Simulation().AssertSqlError(
112+
"select 1 with x as (select 1 as n) select n from x",
113+
319);
114+
115+
// The exact SSMS Object-Explorer AlwaysOn availability probe: three
116+
// statements, only the middle two separated by a semicolon. xp_qv returns
117+
// status 0 (AlwaysOn not available), so ISNULL(@alwayson, -1) yields 0.
118+
[TestMethod]
119+
public void SsmsAlwaysOnProbe_ReturnsZero()
120+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
121+
DECLARE @alwayson INT
122+
EXECUTE @alwayson = master.dbo.xp_qv N'3641190370', @@SERVICENAME;
123+
SELECT ISNULL(@alwayson,-1) AS [AlwaysOn]
124+
"""));
125+
126+
[TestMethod]
127+
[DataRow("exec xp_qv N'x', N'y'")]
128+
[DataRow("exec dbo.xp_qv N'x', N'y'")]
129+
[DataRow("exec master.dbo.xp_qv N'x', N'y'")]
130+
public void XpQv_YieldsNoResultSet(string call)
131+
{
132+
using var reader = new Simulation().ExecuteReader(call);
133+
IsFalse(reader.Read());
134+
}
135+
136+
[TestMethod]
137+
public void XpQv_ReturnStatus_IsZero()
138+
=> AreEqual(0, new Simulation().ExecuteScalar<int>("""
139+
declare @rc int;
140+
exec @rc = xp_qv N'x', N'y';
141+
select @rc
142+
"""));
143+
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,16 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
560560
case ReservedKeyword { Keyword: Keyword.Union or Keyword.Intersect or Keyword.Except or Keyword.Option }:
561561
goto ExitWhileTokenLoop;
562562

563+
// WITH at the start of a projection element is unambiguous:
564+
// it can only mean a CTE-prefixed follow-up statement. Real
565+
// SQL Server raises Msg 319 here rather than the generic
566+
// Msg 156 from the catch-all below — telling the user to
567+
// separate statements with `;`. Checked before the general
568+
// statement-boundary case (which also treats WITH as a
569+
// boundary) so the more specific Msg 319 wins.
570+
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
571+
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
572+
563573
// At the top level (depth 0), the start of another statement
564574
// terminates this SELECT and lets the dispatch loop pick up
565575
// where it left off. Real SQL Server allows back-to-back
@@ -568,24 +578,9 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
568578
// these keywords are still invalid — fall through to the
569579
// generic Msg 156 catch-all below.
570580
case Operator { Character: ';' } when depth == 0:
571-
case ReservedKeyword
572-
{
573-
Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete
574-
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
575-
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
576-
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
577-
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
578-
or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
579-
} when depth == 0:
580581
goto ExitWhileTokenLoop;
581-
582-
// WITH at the start of a projection element is unambiguous:
583-
// it can only mean a CTE-prefixed follow-up statement. Real
584-
// SQL Server raises Msg 319 here rather than the generic
585-
// Msg 156 from the catch-all below — telling the user to
586-
// separate statements with `;`.
587-
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
588-
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
582+
case ReservedKeyword statementStart when depth == 0 && Simulation.IsStatementBoundary(statementStart):
583+
goto ExitWhileTokenLoop;
589584

590585
case ReservedKeyword { Keyword: not Keyword.Null } keyword:
591586
throw SimulatedSqlException.SyntaxErrorNearKeyword(keyword);
@@ -765,26 +760,20 @@ or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
765760
case ReservedKeyword { Keyword: Keyword.Union or Keyword.Intersect or Keyword.Except or Keyword.Option }:
766761
goto ExitWhileTokenLoop;
767762

763+
// WITH at the projection-element-end position can only mean a
764+
// CTE-prefixed follow-up statement; raise Msg 319 to mirror
765+
// SQL Server's specific error here. Checked before the general
766+
// statement-boundary case (which also treats WITH as a
767+
// boundary) so the more specific Msg 319 wins.
768+
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
769+
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
770+
768771
// At the top level (depth 0), the start of another statement
769772
// terminates this SELECT — the dispatch loop picks up there.
770773
// Inside a subquery these keywords stay invalid (fall through
771774
// to the generic Msg 102 below).
772-
case ReservedKeyword
773-
{
774-
Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete
775-
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
776-
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
777-
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
778-
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
779-
or Keyword.Print or Keyword.WaitFor or Keyword.Truncate
780-
} when depth == 0:
775+
case ReservedKeyword statementStart when depth == 0 && Simulation.IsStatementBoundary(statementStart):
781776
goto ExitWhileTokenLoop;
782-
783-
// WITH at the projection-element-end position can only mean a
784-
// CTE-prefixed follow-up statement; raise Msg 319 to mirror
785-
// SQL Server's specific error here.
786-
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
787-
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
788777
}
789778

790779
throw SimulatedSqlException.SyntaxErrorNear(context);

SqlServerSimulator/Simulation/Simulation.Exec.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ partial class Simulation
5858
// here from any current database (real SQL Server resolves
5959
// sp_/xp_ system procs through master).
6060
"xp_msver",
61+
// xp_qv is SSMS's AlwaysOn-availability probe. It yields no
62+
// result set and returns status 0; the simulator reports
63+
// AlwaysOn as not-available (consistent with
64+
// SERVERPROPERTY('IsHadrEnabled') = 0), so the @rc capture
65+
// lands 0 and Object Explorer's Databases node populates.
66+
"xp_qv",
6167
],
6268
collation);
6369
return lookup.TryGetValue(leaf, out var canonical) ? canonical : null;
@@ -152,6 +158,7 @@ private IEnumerable<SimulatedStatementOutcome> ParseExec(BatchContext batch)
152158
"sp_set_session_context" => InvokeSpSetSessionContext(batch),
153159
"sp_updateextendedproperty" => InvokeSpExtendedProperty(batch, ExtendedPropertyOp.Update),
154160
"xp_msver" => InvokeXpMsver(batch),
161+
"xp_qv" => InvokeXpQv(batch, returnCodeVar),
155162
_ => throw new InvalidOperationException($"{systemProcName} is in SystemProcedureNames but has no dispatch arm."),
156163
};
157164
if (systemProc is not null)
@@ -188,7 +195,7 @@ private static List<ProcArgument> ParseExecArguments(ParserContext context, Batc
188195
var arguments = new List<ProcArgument>();
189196
// No args at all — return empty list. The terminator is either `;`,
190197
// end-of-batch, or a statement-starting keyword.
191-
if (IsExecArgumentBoundary(context.Token))
198+
if (IsStatementBoundary(context.Token))
192199
return arguments;
193200

194201
var sawNamed = false;
@@ -262,6 +269,18 @@ private static ProcArgument ParseExecArgument(ParserContext context, BatchContex
262269
return new ProcArgument(name, isDefault: true, value: SqlValue.Null(SqlType.Int32), outputSlot: null);
263270
}
264271

272+
// @@-prefixed niladic function (e.g. @@SERVICENAME, which SSMS's
273+
// AlwaysOn probe passes to xp_qv). Evaluate the single atom in a
274+
// column-less runtime context; session-state forms (@@SPID /
275+
// @@ROWCOUNT / …) read their live value through the batch.
276+
if (context.Token is DoubleAtPrefixedString)
277+
{
278+
var expression = Expression.Parse(context);
279+
var value = expression.Run(new RuntimeContext(
280+
columnName => throw SimulatedSqlException.ColumnReferenceNotAllowed(columnName), batch));
281+
return new ProcArgument(name, isDefault: false, value: value, outputSlot: null);
282+
}
283+
265284
// @variable reference — capture the slot (live, so OUTPUT writeback
266285
// sees the proc's final value), read its value now, and check for a
267286
// trailing OUTPUT / OUT keyword. When the name resolves to a table
@@ -328,12 +347,6 @@ private static SqlValue NegateLiteral(SqlValue v) =>
328347
: v.Type == SqlType.BigInt ? SqlValue.FromInt64(-v.AsInt64)
329348
: v.Type == SqlType.SmallInt ? SqlValue.FromInt16((short)-v.AsInt32)
330349
: v;
331-
332-
private static bool IsExecArgumentBoundary(Token? token) =>
333-
token is null
334-
or Operator { Character: ';' }
335-
or ReservedKeyword { Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc or Keyword.Set or Keyword.Declare or Keyword.With or Keyword.If or Keyword.Else or Keyword.End or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return or Keyword.Print or Keyword.RaisError or Keyword.WaitFor or Keyword.Truncate or Keyword.Exec or Keyword.Execute }
336-
or UnquotedString { ContextualKeyword: ContextualKeyword.Throw };
337350
}
338351

339352
/// <summary>

SqlServerSimulator/Simulation/Simulation.PrincipalDdl.cs

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -165,20 +165,7 @@ internal static bool TryParseDropUser(ParserContext context)
165165
/// </summary>
166166
private static void ConsumeToStatementBoundary(ParserContext context)
167167
{
168-
while (context.Token is not (null
169-
or Operator { Character: ';' }
170-
or ReservedKeyword
171-
{
172-
Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete
173-
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
174-
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
175-
or Keyword.Set or Keyword.Declare or Keyword.With or Keyword.If or Keyword.Else
176-
or Keyword.End or Keyword.While or Keyword.Break or Keyword.Continue
177-
or Keyword.Return or Keyword.Print or Keyword.RaisError or Keyword.WaitFor
178-
or Keyword.Truncate or Keyword.Grant or Keyword.Revoke or Keyword.Deny
179-
}))
180-
{
168+
while (!IsStatementBoundary(context.Token))
181169
context.MoveNextOptional();
182-
}
183170
}
184171
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using SqlServerSimulator.Parser;
2+
using SqlServerSimulator.Storage;
3+
4+
namespace SqlServerSimulator;
5+
6+
partial class Simulation
7+
{
8+
/// <summary>
9+
/// Handles <c>EXEC xp_qv</c> (also <c>dbo.xp_qv</c> /
10+
/// <c>master.dbo.xp_qv</c> from any current database), SSMS's AlwaysOn-
11+
/// availability probe (<c>EXECUTE @rc = master.dbo.xp_qv N'&lt;hash&gt;',
12+
/// @@SERVICENAME</c>). Consumes and ignores its arguments (the feature
13+
/// hash isn't validated), yields no result set, and returns status 0.
14+
/// A real HADR-enabled instance returns 2; the simulator deliberately
15+
/// reports not-available, matching <c>SERVERPROPERTY('IsHadrEnabled')</c>
16+
/// = 0, so <c>SELECT ISNULL(@rc, -1)</c> yields 0 and Object Explorer's
17+
/// Databases node populates instead of the SMO probe aborting.
18+
/// </summary>
19+
private static IEnumerable<SimulatedStatementOutcome> InvokeXpQv(BatchContext batch, string? returnCodeVariableName)
20+
{
21+
_ = ParseExecArguments(batch.Parser, batch);
22+
if (batch.IsSkipping)
23+
yield break;
24+
if (returnCodeVariableName is not null)
25+
{
26+
var slot = batch.GetVariableSlot(returnCodeVariableName);
27+
slot.Value = SqlValue.FromInt32(0).CoerceTo(slot.DeclaredType);
28+
}
29+
}
30+
}

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1380,8 +1380,16 @@ private IEnumerable<SimulatedStatementOutcome> DispatchOneStatementCore(BatchCon
13801380
/// batch, a recognized statement-starting keyword, or the <c>END</c>
13811381
/// terminator of a BEGIN…END block. Used to decide whether to re-normalize
13821382
/// a parser's leftover cursor position.
1383+
///
1384+
/// This is the single source of truth for "does this token begin a new
1385+
/// top-level statement (or a hard boundary)?" — the dispatch loop, the
1386+
/// EXEC-argument scanner, the principal-DDL parse-and-discard tail, and the
1387+
/// SELECT projection-list terminator all route through it, so a new
1388+
/// statement keyword is added in exactly one place. SQL Server accepts
1389+
/// back-to-back statements without a separating <c>;</c>; every consumer
1390+
/// mirrors that by treating the full keyword set uniformly as a boundary.
13831391
/// </summary>
1384-
private static bool IsStatementBoundary(Token? token) =>
1392+
internal static bool IsStatementBoundary(Token? token) =>
13851393
token is null
13861394
or Operator { Character: ';' }
13871395
or ReservedKeyword
@@ -1394,6 +1402,7 @@ or Keyword.End or Keyword.While or Keyword.Break or Keyword.Continue
13941402
or Keyword.Return or Keyword.Print or Keyword.RaisError or Keyword.WaitFor
13951403
or Keyword.Truncate or Keyword.Use or Keyword.Grant or Keyword.Revoke or Keyword.Deny
13961404
or Keyword.Open or Keyword.Fetch or Keyword.Close or Keyword.Deallocate
1405+
or Keyword.Exec or Keyword.Execute
13971406
}
13981407
// THROW is a contextual keyword in SQL Server's grammar — added with
13991408
// the TRY/CATCH companion feature in 2012, not in the reserved list.

0 commit comments

Comments
 (0)