Skip to content

Commit b34392c

Browse files
committed
Enforce Msg 111 (CREATE/ALTER PROCEDURE/FUNCTION/VIEW/TRIGGER/SCHEMA must be the first statement in a batch). New BatchContext.BlockDepth + HasDispatchedStatement track scope; per-parser check at entry raises with probed wording (PROCEDURE merges CREATE/ALTER; VIEW/FUNCTION/TRIGGER/SCHEMA use separate labels; ALTER TRIGGER differs from CREATE TRIGGER). Inside IF/WHILE/BEGIN-END bodies, BlockDepth > 0 triggers Msg 111 (real SQL Server raises Msg 156 at that position; same end state). Inner BatchContexts (proc/function/trigger/dynamic-SQL bodies) reset the flag, so CREATE PROCEDURE as the first statement of a proc body works. Replaces a silent bug where the first proc's body greedily captured subsequent CREATE PROCEDURE statements as text. New simulation.ExecuteBatches(params) test helper for splitting setup; ~22 existing tests updated to use separate batches for must-be-first statements; 13 new BatchBoundaryTests pin the wording per kind. CLAUDE.md notes the IF/WHILE-body Msg 111-vs-156 divergence.
1 parent 29c3299 commit b34392c

20 files changed

Lines changed: 418 additions & 228 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,4 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
194194
- **`IF (1) select` paren-wrapped non-boolean cond — slight positional gap**: simulator raises Msg 4145 near `')'`; real SQL Server reports `'select'` (the post-paren token). Wording is correct (Msg 4145, non-boolean type), only the "near 'X'" suffix differs. Same gap applies to any `IF (value-expr) …` shape.
195195
- **`REPLICATE` of a column-typed `varchar(MAX)` / `nvarchar(MAX)` truncates to 8000 bytes**: the simulator's runtime `SqlValue` doesn't carry the varchar / nvarchar declared length — both bounded `varchar(N)` and `varchar(MAX)` collapse to the length-agnostic singleton at the value level. `Replicate` captures the MAX-vs-bounded distinction at parse time via `Expression.GetSqlType`, which works for literal-only or CAST-target inputs (the common shape) but falls back to "treat as bounded" for column references because the parse-time outer-type resolver doesn't reach FROM-source column types. Threading the projection-time resolver through would lift this; EF's REPLICATE emissions don't hit the affected path.
196196
- **`DATALENGTH` returns `int` for MAX-typed inputs**: real SQL Server returns `bigint` for `varchar(MAX)` / `nvarchar(MAX)` and the legacy LOB family. Pre-existing simulator divergence; the result still fits in int for any value the simulator can produce, but the declared column type doesn't widen.
197+
- **`CREATE/ALTER PROCEDURE / FUNCTION / VIEW / TRIGGER / SCHEMA` inside a control-flow body raises Msg 111, not Msg 156**: the must-be-first-statement check is enforced at parse-time per probed wording (PROCEDURE merges CREATE/ALTER into one label; the others use separate `CREATE` / `ALTER` labels). Inside `IF` / `WHILE` / `BEGIN…END`, `BatchContext.BlockDepth > 0` triggers Msg 111; real SQL Server's parser surfaces Msg 156 ("Incorrect syntax near 'procedure'") at the same position. Same end state — the statement is rejected — different error code. Inner CommandText-equivalent contexts (procedure / function / trigger / dynamic-SQL bodies) get a fresh `BatchContext` and the flag resets, so a CREATE PROCEDURE as the first statement of a proc body succeeds (real SQL Server also raises Msg 156 here, a related minor divergence; no real application emits nested CREATE PROCEDUREs).

SqlServerSimulator.Tests.EFCore/EFCoreTriggers.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
4848
public void HasTrigger_Insert_FiresAndAuditPopulated()
4949
{
5050
var simulation = new Simulation();
51-
_ = simulation.ExecuteNonQuery("""
51+
simulation.ExecuteBatches(
52+
"""
5253
create table Products (
5354
Id int identity(1,1) primary key,
5455
Name nvarchar(50) not null,
@@ -59,10 +60,12 @@ create table ProductAudit (
5960
NewName nvarchar(50) not null,
6061
NewPrice int not null
6162
);
63+
""",
64+
"""
6265
create trigger tr_product_audit on Products after insert
6366
as
6467
insert ProductAudit(ProductId, NewName, NewPrice)
65-
select Id, Name, Price from inserted;
68+
select Id, Name, Price from inserted
6669
""");
6770
using var context = new TriggerDbContext(simulation);
6871
_ = context.Products.Add(new Product { Name = "Widget", Price = 100 });
@@ -94,15 +97,18 @@ public void HasTrigger_SaveChanges_RetrievesGeneratedIdentity()
9497
// round-trip — EF reads Id back from the database after the
9598
// trigger fires, and the in-memory entity reflects it.
9699
var simulation = new Simulation();
97-
_ = simulation.ExecuteNonQuery("""
100+
simulation.ExecuteBatches(
101+
"""
98102
create table Products (
99103
Id int identity(1,1) primary key,
100104
Name nvarchar(50) not null,
101105
Price int not null
102-
);
106+
)
107+
""",
108+
"""
103109
create trigger tr_product_audit on Products after insert
104110
as
105-
select 1; -- no-op body; verify identity round-trip works through trigger-safe shape
111+
select 1
106112
""");
107113
using var context = new TriggerDbContext(simulation);
108114
var p1 = new Product { Name = "First", Price = 10 };

SqlServerSimulator.Tests.EFCore/EFCoreViews.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ public sealed class EFCoreViews
1616
private static ViewDbContext WithOrderSummary()
1717
{
1818
var simulation = new Simulation();
19-
_ = simulation.ExecuteNonQuery("""
19+
simulation.ExecuteBatches(
20+
"""
2021
create table Orders (Id int primary key, Customer varchar(50), Amount decimal(10,2));
2122
insert Orders values (1, 'Alice', 100.00), (2, 'Bob', 250.00), (3, 'Alice', 50.00);
22-
create view OrderSummary as select Customer, sum(Amount) as TotalAmount, count(*) as OrderCount from Orders group by Customer
23-
""");
23+
""",
24+
"create view OrderSummary as select Customer, sum(Amount) as TotalAmount, count(*) as OrderCount from Orders group by Customer");
2425
return new ViewDbContext(simulation);
2526
}
2627

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
namespace SqlServerSimulator;
2+
3+
/// <summary>
4+
/// Verifies <c>CREATE/ALTER PROCEDURE / FUNCTION / VIEW / TRIGGER / SCHEMA</c>
5+
/// raises Msg 111 ("'X' must be the first statement in a query batch.") when
6+
/// it isn't the first statement. Probe-confirmed wording per kind: PROCEDURE
7+
/// merges CREATE / ALTER into one label; the others use their separate
8+
/// CREATE / ALTER labels. Inner CommandText-equivalent contexts (procedure,
9+
/// function, trigger, dynamic-SQL bodies) get a fresh BatchContext so the
10+
/// check naturally resets — proven by the IFBlock + WHILE body cases below
11+
/// (which raise Msg 111 because BlockDepth &gt; 0).
12+
/// </summary>
13+
[TestClass]
14+
public class BatchBoundaryTests
15+
{
16+
[TestMethod]
17+
[DataRow("create schema audit", "create schema staging", "CREATE SCHEMA")]
18+
[DataRow("create table t (id int)", "create view v as select 1 as x", "CREATE VIEW")]
19+
[DataRow("create table t (id int)", "create function fn() returns int as begin return 1 end", "CREATE FUNCTION")]
20+
[DataRow("create table t (id int)", "create proc p as select 1", "CREATE/ALTER PROCEDURE")]
21+
[DataRow("create table t (id int)", "create trigger tr on t after insert as select 1", "CREATE TRIGGER")]
22+
public void CreateMustBeFirstStatement_RaisesMsg111(string first, string secondCreate, string expectedLabel)
23+
=> new Simulation().AssertSqlError(
24+
$"{first}; {secondCreate}",
25+
111,
26+
$"'{expectedLabel}' must be the first statement in a query batch.");
27+
28+
[TestMethod]
29+
public void AlterProcedureMustBeFirstStatement_RaisesMsg111()
30+
{
31+
var simulation = new Simulation();
32+
simulation.ExecuteBatches("create proc p as select 1");
33+
simulation.AssertSqlError(
34+
"declare @x int = 1; alter proc p as select 2",
35+
111,
36+
"'CREATE/ALTER PROCEDURE' must be the first statement in a query batch.");
37+
}
38+
39+
[TestMethod]
40+
public void AlterTriggerMustBeFirstStatement_RaisesMsg111()
41+
{
42+
var simulation = new Simulation();
43+
simulation.ExecuteBatches(
44+
"create table t (id int)",
45+
"create trigger tr on t after insert as select 1");
46+
simulation.AssertSqlError(
47+
"declare @x int = 1; alter trigger tr on t after insert as select 2",
48+
111,
49+
"'ALTER TRIGGER' must be the first statement in a query batch.");
50+
}
51+
52+
[TestMethod]
53+
public void CreateOrAlterProcedure_UsesProcedureLabel()
54+
=> new Simulation().AssertSqlError(
55+
"declare @x int = 1; create or alter procedure p as select 2",
56+
111,
57+
"'CREATE/ALTER PROCEDURE' must be the first statement in a query batch.");
58+
59+
[TestMethod]
60+
public void LeadingSemicolons_DontCountAsFirstStatement()
61+
=> new Simulation().ExecuteNonQuery("; create proc p as select 1");
62+
63+
[TestMethod]
64+
public void CreateProcInsideIfBlock_RaisesMsg111()
65+
=> new Simulation().AssertSqlError(
66+
"if 1 = 1 begin create proc p as select 1 end",
67+
111,
68+
"'CREATE/ALTER PROCEDURE' must be the first statement in a query batch.");
69+
70+
[TestMethod]
71+
public void CreateProcInsideWhileBody_RaisesMsg111()
72+
=> new Simulation().AssertSqlError(
73+
"while 1 = 0 begin create proc p as select 1 end",
74+
111,
75+
"'CREATE/ALTER PROCEDURE' must be the first statement in a query batch.");
76+
77+
[TestMethod]
78+
public void CreateProcAsFirstStatement_Works()
79+
=> new Simulation().ExecuteNonQuery("create proc p as select 1");
80+
81+
[TestMethod]
82+
public void ProcedureBodyIsItsOwnBatch_AllowsCreateProc()
83+
{
84+
// Inside a proc body, the dispatched statements form a new batch with
85+
// its own BatchContext, so CREATE PROCEDURE may legitimately appear
86+
// as the first statement of the body. (Real SQL Server actually
87+
// raises Msg 156 syntax error in this position; the simulator's
88+
// divergence here is documented and benign — no app emits nested
89+
// CREATE PROCEDUREs inside a proc body.)
90+
var simulation = new Simulation();
91+
simulation.ExecuteBatches(
92+
"create proc outer_p as create proc inner_p as select 1",
93+
"exec outer_p"); // Invoke the outer proc — its body creates inner_p.
94+
Assert.AreEqual(1, simulation.ExecuteScalar("select count(*) from sys.procedures where name = 'inner_p'"));
95+
}
96+
}

SqlServerSimulator.Tests/CatalogViewTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ select schema_id('audit')
3838

3939
[TestMethod]
4040
public void SchemaId_TwoUserSchemas_5And6()
41-
=> AreEqual(11, new Simulation().ExecuteScalar("""
42-
create schema audit;
43-
create schema staging;
44-
select schema_id('audit') + schema_id('staging')
45-
"""));
41+
{
42+
var simulation = new Simulation();
43+
simulation.ExecuteBatches("create schema audit", "create schema staging");
44+
AreEqual(11, simulation.ExecuteScalar("select schema_id('audit') + schema_id('staging')"));
45+
}
4646

4747
[TestMethod]
4848
public void SchemaId_Missing_ReturnsNull()

0 commit comments

Comments
 (0)