Skip to content

Commit 30771f0

Browse files
committed
Statement separators: ; is optional between most statements (declare/select, set/set, insert/select, begin/commit, etc.) — dispatch loop drops the per-iteration MoveNext-at-top, drains explicit ;s, and trusts each parser's lookahead-position
contract; IsStatementBoundary normalizes parsers that historically ended on the last consumed token (DBCC's ), SET-session-state's ON/OFF). Two probe-confirmed exceptions stay enforced: WITH directly following another statement raises Msg 319 (verbatim wording, with the check firing both at top-level dispatch and inside Selection.Parse's projection-element switches so select 0 with cte ... surfaces before the SELECT can complete); MERGE not terminated by ; raises Msg 10713 immediately after ParseMerge returns. Selection.Parse's pre- and post-projection switches gain statement-keyword terminator cases at depth 0 so back-to-back select 1 select 2 yields two result sets instead of Msg 156, and the same WITH-special-case sits next to them. Removes the "implicit statement separation" Not-modeled gap.
1 parent c03193c commit 30771f0

5 files changed

Lines changed: 344 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ Readonly struct, up to 4 inline slots (SQL Server's grammar limit). API: `Leaf`,
8282

8383
The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contract. Notes below cover only probe-confirmed quirks, deviations from SQL Server, and non-obvious implementation rules.
8484

85+
### Batch grammar: statement separators
86+
Statements are separated by an optional `;`. Real SQL Server's relaxed grammar lets most statement pairs sit adjacent (`declare @v int = 7 select @v`, `set @v = 1 set @w = 2`, `insert t values (1) select * from t`, `begin tran ... commit`); the simulator follows. Two enforced exceptions match SQL Server's specific rules:
87+
- A CTE (`WITH`) directly following another statement raises **Msg 319 St 1** (verbatim wording: `Incorrect syntax near the keyword 'with'. If this statement is a common table expression, an xmlnamespaces clause or a change tracking context clause, the previous statement must be terminated with a semicolon.`). A `WITH` at batch start (or right after a `;`) is fine. The check fires both at `Simulation.CreateResultSetsForCommand`'s top-level dispatch (`requireSemicolonBeforeCte` flag) and inside `Selection.Parse`'s projection-element switches — the latter is where `select 0 with cte ...` surfaces it before the SELECT can complete.
88+
- A `MERGE` not terminated by `;` raises **Msg 10713 St 1** (`A MERGE statement must be terminated by a semi-colon (;).`) regardless of whether another statement follows or the batch ends. The check sits at the dispatch site immediately after `ParseMerge` returns, before any cursor normalization.
89+
90+
The dispatch loop drains optional `;`s at the top of each iteration and trusts each parser to leave `Token` at its first un-consumed token (the `ParserContext` lookahead-position contract). Parsers that historically ended on the last token they consumed (DBCC's closing `)`, SET-session-state's `ON`/`OFF`) get a one-token advance via `IsStatementBoundary` after dispatch — Token already at `;`, end-of-batch, or a recognized statement-starting keyword is left alone.
91+
8592
### Boolean / set ops / projection / CASE
8693
- Boolean combinators (WHERE / MERGE-ON / CHECK): `AND` / `OR` / `NOT`, parens, `IS [NOT] NULL`, `[NOT] IN (literal,...)`. Tri-valued.
8794
- Set ops (UNION / UNION ALL / INTERSECT / EXCEPT): standard precedence (INTERSECT > UNION/EXCEPT). **NULLs are equal during set-op dedup/matching** (opposite of `=`'s tri-state). Per-branch ORDER BY in non-final branch → Msg 156. Top-level ORDER BY references first-branch column names only.
@@ -267,7 +274,7 @@ Variable references resolve at runtime via a captured `VariableSlot` — require
267274

268275
**`@@ROWCOUNT`**: tracks the most-recently-completed statement's row count via `Simulation.LastStatementRowCount`. SELECT row counts populate after the dispatch materializes rows up-front (so the next statement in the batch sees the final count); DML mutations write their affected count; `SET` / `DECLARE @v = init` write 1; bare `DECLARE @v` (no initializer) preserves the prior count; transaction / DDL statements reset to 0.
269276

270-
**Compound assignment** (`SET @v += expr` etc.) and **table variables** (`DECLARE @t TABLE (...)`) aren't modeled — rewrite as `SET @v = @v + expr` for the former; the latter is a separate bundle. Statements within a batch require `;` separators (existing simulator-wide convention; real SQL Server accepts implicit separation in many cases).
277+
**Compound assignment** (`SET @v += expr` etc.) and **table variables** (`DECLARE @t TABLE (...)`) aren't modeled — rewrite as `SET @v = @v + expr` for the former; the latter is a separate bundle.
271278

272279
### Common table expressions
273280
`WITH name [(col, …)] AS (SELECT …) [, …] {SELECT|INSERT|UPDATE|DELETE|MERGE} …`. WITH prefix scopes to exactly one immediately-following statement. Both non-recursive and recursive forms modeled.
@@ -352,7 +359,6 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
352359
- Table variables (`DECLARE @t TABLE (...)`) — separate feature with its own storage / scope / lifecycle.
353360
- T-SQL control flow (`IF` / `WHILE` / `BEGIN ... END` / `BREAK` / `CONTINUE`) — Bundle 2 of scripting.
354361
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `RETURN`, `PRINT`, stored procs / UDFs.
355-
- Implicit statement separation: real SQL Server accepts `declare @v int = 7 select @v` without `;`; simulator requires explicit semicolons between statements when one ends with an expression.
356362
- `hierarchyid`, `geography`, `geometry`.
357363

358364
## Quirks (modeled, not byte-identical to SQL Server)
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
using static SqlServerSimulator.TestHelpers;
3+
4+
namespace SqlServerSimulator;
5+
6+
[TestClass]
7+
public sealed class OptionalSemicolonTests
8+
{
9+
[TestMethod]
10+
public void DeclareThenSelect_NoSeparator_Works()
11+
=> AreEqual(7, ExecuteScalar<int>("declare @v int = 7 select @v"));
12+
13+
[TestMethod]
14+
public void SetThenSet_NoSeparator_Works()
15+
{
16+
using var conn = new Simulation().CreateOpenConnection();
17+
using var cmd = conn.CreateCommand("declare @v int, @w int set @v = 1 set @w = 2 select @v + @w");
18+
AreEqual(3, cmd.ExecuteScalar());
19+
}
20+
21+
[TestMethod]
22+
public void SelectThenSelect_NoSeparator_YieldsTwoResultSets()
23+
{
24+
using var conn = new Simulation().CreateOpenConnection();
25+
using var cmd = conn.CreateCommand("select 1 as a select 2 as b");
26+
using var reader = cmd.ExecuteReader();
27+
IsTrue(reader.Read());
28+
AreEqual(1, reader.GetInt32(0));
29+
IsTrue(reader.NextResult());
30+
IsTrue(reader.Read());
31+
AreEqual(2, reader.GetInt32(0));
32+
}
33+
34+
[TestMethod]
35+
public void InsertThenSelect_NoSeparator_Works()
36+
{
37+
using var conn = new Simulation().CreateOpenConnection();
38+
_ = conn.CreateCommand("create table t (id int)").ExecuteNonQuery();
39+
AreEqual(1, conn.CreateCommand("insert t values (1) select count(*) from t").ExecuteScalar());
40+
}
41+
42+
[TestMethod]
43+
public void BeginTranThenRollback_NoSeparator_Works()
44+
{
45+
var sim = new Simulation();
46+
_ = sim.ExecuteNonQuery("create table t (id int)");
47+
_ = sim.ExecuteNonQuery("begin tran insert t values (1) rollback");
48+
AreEqual(0, sim.ExecuteScalar<int>("select count(*) from t"));
49+
}
50+
51+
[TestMethod]
52+
public void CreateInsertSelect_NoSeparators_Works()
53+
{
54+
using var conn = new Simulation().CreateOpenConnection();
55+
AreEqual(1, conn.CreateCommand(
56+
"create table t (id int) insert t values (1) select count(*) from t").ExecuteScalar());
57+
}
58+
59+
[TestMethod]
60+
public void NewlineBetweenStatements_NoSeparator_Works()
61+
=> AreEqual(7, ExecuteScalar<int>("declare @v int = 7\nselect @v"));
62+
63+
[TestMethod]
64+
public void DoubleSemicolon_Works()
65+
{
66+
using var conn = new Simulation().CreateOpenConnection();
67+
using var cmd = conn.CreateCommand("select 1 as a;;select 2 as b");
68+
using var reader = cmd.ExecuteReader();
69+
IsTrue(reader.Read());
70+
AreEqual(1, reader.GetInt32(0));
71+
IsTrue(reader.NextResult());
72+
IsTrue(reader.Read());
73+
AreEqual(2, reader.GetInt32(0));
74+
}
75+
76+
[TestMethod]
77+
public void TrailingSemicolons_Works()
78+
=> AreEqual(1, ExecuteScalar<int>("select 1;;;"));
79+
80+
[TestMethod]
81+
public void LeadingSemicolons_Works()
82+
=> AreEqual(1, ExecuteScalar<int>(";;select 1"));
83+
84+
[TestMethod]
85+
public void EmptyBatchOfSemicolons_NoOp()
86+
{
87+
// SqlClient's ExecuteNonQuery returns -1 when no DML statement ran;
88+
// a batch of bare `;`s should produce that sentinel rather than
89+
// throwing.
90+
using var conn = new Simulation().CreateOpenConnection();
91+
AreEqual(-1, conn.CreateCommand(";;").ExecuteNonQuery());
92+
}
93+
94+
[TestMethod]
95+
public void CteAtBatchStart_NoSemicolonNeeded()
96+
=> AreEqual(1, ExecuteScalar<int>("with cte as (select 1 as x) select x from cte"));
97+
98+
[TestMethod]
99+
public void CteAfterSemicolon_Works()
100+
{
101+
using var conn = new Simulation().CreateOpenConnection();
102+
using var cmd = conn.CreateCommand("select 0 as a;with cte as (select 1 as x) select x from cte");
103+
using var reader = cmd.ExecuteReader();
104+
IsTrue(reader.Read());
105+
AreEqual(0, reader.GetInt32(0));
106+
IsTrue(reader.NextResult());
107+
IsTrue(reader.Read());
108+
AreEqual(1, reader.GetInt32(0));
109+
}
110+
111+
[TestMethod]
112+
public void CteWithoutPrecedingSemicolon_RaisesMsg319()
113+
=> AssertSqlError(
114+
"select 0 as a with cte as (select 1 as x) select x from cte",
115+
319,
116+
"Incorrect syntax near the keyword 'with'. If this statement is a common table expression, an xmlnamespaces clause or a change tracking context clause, the previous statement must be terminated with a semicolon.");
117+
118+
[TestMethod]
119+
public void TwoCtesWithoutSemicolon_RaisesMsg319()
120+
{
121+
// Even when both statements are CTE-prefixed, the second WITH still
122+
// requires a `;` to separate it from the prior statement. The error
123+
// surfaces when the dispatch loop advances to the second WITH —
124+
// which means the test has to drive iteration past the first result
125+
// set; ExecuteScalar would short-circuit.
126+
using var conn = new Simulation().CreateOpenConnection();
127+
var ex = Throws<System.Data.Common.DbException>(() =>
128+
conn.CreateCommand(
129+
"with c1 as (select 1 as x) select x from c1 with c2 as (select 2 as y) select y from c2")
130+
.ExecuteNonQuery());
131+
AreEqual("319", ex.Data["HelpLink.EvtID"]);
132+
}
133+
134+
[TestMethod]
135+
public void MergeWithTrailingSemicolon_Works()
136+
{
137+
var sim = new Simulation();
138+
_ = sim.ExecuteNonQuery("create table dst (id int)");
139+
_ = sim.ExecuteNonQuery("merge dst using (values (1)) v(x) on 1=0 when not matched then insert (id) values (v.x);");
140+
AreEqual(1, sim.ExecuteScalar<int>("select count(*) from dst"));
141+
}
142+
143+
[TestMethod]
144+
public void MergeWithoutTrailingSemicolon_RaisesMsg10713()
145+
{
146+
var sim = new Simulation();
147+
_ = sim.ExecuteNonQuery("create table dst (id int)");
148+
sim.AssertSqlError(
149+
"merge dst using (values (1)) v(x) on 1=0 when not matched then insert (id) values (v.x)",
150+
10713,
151+
"A MERGE statement must be terminated by a semi-colon (;).");
152+
}
153+
154+
[TestMethod]
155+
public void MergeFollowedByAnotherStatementWithoutTerminator_RaisesMsg10713()
156+
{
157+
var sim = new Simulation();
158+
_ = sim.ExecuteNonQuery("create table dst (id int)");
159+
_ = sim.AssertSqlError(
160+
"merge dst using (values (1)) v(x) on 1=0 when not matched then insert (id) values (v.x) select 1",
161+
10713);
162+
}
163+
164+
[TestMethod]
165+
public void MergeThenSelectWithSemicolon_Works()
166+
{
167+
using var conn = new Simulation().CreateOpenConnection();
168+
_ = conn.CreateCommand("create table dst (id int)").ExecuteNonQuery();
169+
AreEqual(
170+
1,
171+
conn.CreateCommand(
172+
"merge dst using (values (1)) v(x) on 1=0 when not matched then insert (id) values (v.x);select count(*) from dst")
173+
.ExecuteScalar());
174+
}
175+
176+
[TestMethod]
177+
public void DbccFollowedBySelect_NoSeparator_Works()
178+
{
179+
// DBCC TRACEON's parser ends on the closing `)` (last consumed) rather
180+
// than the lookahead. The dispatch loop normalizes by advancing one
181+
// token when the cursor isn't already at a statement boundary; this
182+
// test exercises that path.
183+
AreEqual(1, ExecuteScalar<int>("dbcc traceon(460) select 1"));
184+
}
185+
186+
[TestMethod]
187+
public void AlterDatabaseFollowedBySelect_NoSeparator_Works()
188+
=> AreEqual(2, ExecuteScalar<int>(
189+
"alter database current set compatibility_level = 160 select 2"));
190+
191+
[TestMethod]
192+
public void DeclareSetSelect_NoSeparators_Works()
193+
{
194+
using var conn = new Simulation().CreateOpenConnection();
195+
AreEqual(15, conn.CreateCommand(
196+
"declare @x int = 7 set @x = @x + 8 select @x").ExecuteScalar());
197+
}
198+
}

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,29 @@ internal static SimulatedSqlException NoCorrespondingBeginCommit() =>
4747
/// </summary>
4848
internal static SimulatedSqlException NoCorrespondingBeginRollback() =>
4949
new("The ROLLBACK TRANSACTION request has no corresponding BEGIN TRANSACTION.", 3903, 16, 1);
50+
51+
/// <summary>
52+
/// Mimics SQL Server error 319: a CTE-prefixed statement (a <c>WITH</c>
53+
/// clause introducing a common table expression) followed another
54+
/// statement with no <c>;</c> separator. Probe-confirmed verbatim text /
55+
/// Class 15 / State 1. The wording is structural: real SQL Server lists
56+
/// every grammar slot where <c>WITH</c> appears (CTE, xmlnamespaces,
57+
/// change-tracking context) since the parser can't distinguish at this
58+
/// point. A <c>WITH</c> at batch start, or immediately after a <c>;</c>,
59+
/// is fine — only a back-to-back <c>statement WITH cte</c> sequence
60+
/// triggers this.
61+
/// </summary>
62+
internal static SimulatedSqlException CteRequiresPrecedingSemicolon() =>
63+
new("Incorrect syntax near the keyword 'with'. If this statement is a common table expression, an xmlnamespaces clause or a change tracking context clause, the previous statement must be terminated with a semicolon.", 319, 15, 1);
64+
65+
/// <summary>
66+
/// Mimics SQL Server error 10713: a <c>MERGE</c> statement was not
67+
/// followed by a <c>;</c>. Probe-confirmed verbatim text (note the
68+
/// hyphenated <c>"semi-colon"</c>) / Class 15 / State 1. <c>MERGE</c> is
69+
/// the only statement family the server requires to be terminated with a
70+
/// semicolon, regardless of whether another statement follows or the
71+
/// batch ends.
72+
/// </summary>
73+
internal static SimulatedSqlException MergeMustBeTerminated() =>
74+
new("A MERGE statement must be terminated by a semi-colon (;).", 10713, 15, 1);
5075
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,31 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
373373
case ReservedKeyword { Keyword: Keyword.Union or Keyword.Intersect or Keyword.Except }:
374374
goto ExitWhileTokenLoop;
375375

376+
// At the top level (depth 0), the start of another statement
377+
// terminates this SELECT and lets the dispatch loop pick up
378+
// where it left off. Real SQL Server allows back-to-back
379+
// statements without `;` between them; we mirror by stopping
380+
// the projection-list parse here. Inside a subquery (depth > 0)
381+
// these keywords are still invalid — fall through to the
382+
// generic Msg 156 catch-all below.
383+
case Operator { Character: ';' } when depth == 0:
384+
case ReservedKeyword
385+
{
386+
Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete
387+
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
388+
or Keyword.Save or Keyword.Create or Keyword.Alter or Keyword.Dbcc
389+
or Keyword.Set or Keyword.Declare
390+
} when depth == 0:
391+
goto ExitWhileTokenLoop;
392+
393+
// WITH at the start of a projection element is unambiguous:
394+
// it can only mean a CTE-prefixed follow-up statement. Real
395+
// SQL Server raises Msg 319 here rather than the generic
396+
// Msg 156 from the catch-all below — telling the user to
397+
// separate statements with `;`.
398+
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
399+
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
400+
376401
case ReservedKeyword { Keyword: not Keyword.Null } keyword:
377402
throw SimulatedSqlException.SyntaxErrorNearKeyword(keyword);
378403

@@ -481,6 +506,25 @@ private static Selection ParseInner(ParserContext context, uint depth, List<Aggr
481506
// driver (ParseQueryExpression) can chain branches.
482507
case ReservedKeyword { Keyword: Keyword.Union or Keyword.Intersect or Keyword.Except }:
483508
goto ExitWhileTokenLoop;
509+
510+
// At the top level (depth 0), the start of another statement
511+
// terminates this SELECT — the dispatch loop picks up there.
512+
// Inside a subquery these keywords stay invalid (fall through
513+
// to the generic Msg 102 below).
514+
case ReservedKeyword
515+
{
516+
Keyword: Keyword.Select or Keyword.Insert or Keyword.Update or Keyword.Delete
517+
or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
518+
or Keyword.Save or Keyword.Create or Keyword.Alter or Keyword.Dbcc
519+
or Keyword.Set or Keyword.Declare
520+
} when depth == 0:
521+
goto ExitWhileTokenLoop;
522+
523+
// WITH at the projection-element-end position can only mean a
524+
// CTE-prefixed follow-up statement; raise Msg 319 to mirror
525+
// SQL Server's specific error here.
526+
case ReservedKeyword { Keyword: Keyword.With } when depth == 0:
527+
throw SimulatedSqlException.CteRequiresPrecedingSemicolon();
484528
}
485529

486530
throw SimulatedSqlException.SyntaxErrorNear(context);

0 commit comments

Comments
 (0)