Skip to content

Commit 9865049

Browse files
committed
Add T-SQL PRINT (parses + evaluates operand so type errors like Msg 245 still surface; discards the message — DbConnection has no InfoMessage event and no app has needed observation yet; @@rowcount resets to 0 per probe; skip-mode gates evaluation; Msg 1046 scalar-subquery rejection and 8000/4000-char truncation are documented fidelity gaps).
1 parent 1dfc045 commit 9865049

5 files changed

Lines changed: 239 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,19 @@ Each statement parser still runs its full parse — the cursor advances normally
370370

371371
**Fidelity gap** (Q15): real SQL Server defers name resolution for un-taken branches — `IF 1=0 SELECT bad_col FROM bad_table` runs silently. The simulator's parsers do name resolution inline with parsing, so un-taken branches with non-existent table/column refs still raise Msg 208 / Msg 207. Common idioms (safe-CREATE / safe-DROP / safe-INSERT against pre-existing tables) work end-to-end because referenced names exist when the branch is skipped.
372372

373+
### `PRINT`
374+
`PRINT <expression>` parses + evaluates the operand and **discards the result** — no `InfoMessage` event on `SimulatedDbConnection` (DbConnection doesn't define one, and no application has needed PRINT output observation yet). The evaluation isn't a no-op: operand-side errors still surface — `PRINT 'val=' + 5` raises Msg 245 because the `+` operator's int-side promotion tries to parse `'val='` as int (probe-confirmed against SQL Server 2025).
375+
376+
Probe-confirmed semantics (2026-05-11) the simulator handles correctly because evaluation runs unchanged:
377+
- `PRINT NULL` and `PRINT ''` are silent no-ops (no message body, no error).
378+
- `PRINT` resets `@@ROWCOUNT` to 0 — applied by the dispatcher after the parser returns.
379+
- Skip-mode (un-taken IF, after BREAK / CONTINUE / RETURN) suppresses operand evaluation entirely, so an error-bearing operand in a skipped branch doesn't fire. Standard pattern: parse the expression unconditionally to advance the cursor, then gate `expression.Run` on `!batch.IsSkipping`.
380+
- Rollback doesn't undo a PRINT (real SQL Server's InfoMessage stream is non-transactional too); orthogonal to the simulator's discard-everything design.
381+
382+
**Fidelity gaps** (modeled deviations from probed behavior):
383+
- Real SQL Server's PRINT truncates messages at 8000 chars (varchar) / 4000 chars (nvarchar). Simulator: no truncation modeled (output is discarded).
384+
- Real SQL Server raises **Msg 1046** ("Subqueries are not allowed in this context. Only scalar expressions are allowed.") for `PRINT (SELECT 'inner')`. The simulator silently evaluates the scalar subquery — Msg 1046 isn't modeled.
385+
373386
### Local temp tables (`#foo`)
374387
Per-connection `Dictionary<string, HeapTable> TempTables` on `SimulatedDbConnection`; routed by `BatchContext.TryResolveTable` (`#`-prefix → connection dict, else current DB + system tables). Auto-cleared on `Dispose`, matching real SQL Server's session-close drop. Lifecycle, cross-conn isolation, and Msg 208 from other sessions all probe-confirmed against SQL Server 2025.
375388

@@ -414,7 +427,8 @@ Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the c
414427
- **`ALTER TABLE #foo`**, **`TRUNCATE TABLE #foo`**, **`OBJECT_ID('tempdb..#foo')`** — none modeled (none of those exist for regular tables either yet). The common `IF OBJECT_ID(...) IS NOT NULL DROP TABLE` cleanup pattern works via `DROP TABLE IF EXISTS #foo` instead.
415428
- **Three-part name resolution outside DROP TABLE**: `tempdb..#foo` in FROM / INSERT / UPDATE / DELETE / MERGE / SET IDENTITY_INSERT raises `InvalidObjectName` (Msg 208) on the qualifier; use bare `#foo`. DROP TABLE alone tolerates the qualifier (probe pattern).
416429
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare) ship; unconditional jumps don't.
417-
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, `PRINT`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`). Value-form `RETURN N` raises Msg 178 (reserved for the stored-proc / function scope, neither modeled yet).
430+
- `TRY ... CATCH`, `THROW`, `RAISERROR`, `@@ERROR`, stored procs / UDFs. `BEGIN TRY` / `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch (peeked after `BEGIN`). Value-form `RETURN N` raises Msg 178 (reserved for the stored-proc / function scope, neither modeled yet).
431+
- **`PRINT` message capture** — the statement parses + evaluates the operand (so operand-side errors like Msg 245 surface), but the message is discarded. `DbConnection` has no `InfoMessage` event (that's a `SqlConnection` extension), so adding a public observability surface would mean a new event on `SimulatedDbConnection`. Defer until an application needs it.
418432
- `hierarchyid`, `geography`, `geometry`.
419433

420434
## Quirks (modeled, not byte-identical to SQL Server)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Tests for the <c>PRINT</c> statement. The simulator parses + evaluates
7+
/// the operand (so type / coercion errors surface naturally) and discards
8+
/// the result — <see cref="SimulatedDbConnection"/> doesn't expose an
9+
/// <c>InfoMessage</c> event because <c>DbConnection</c> doesn't define one
10+
/// and there's no demonstrated need for the public surface yet. The tests
11+
/// verify side-effect-free behavior (<c>@@ROWCOUNT</c> reset, skip-mode
12+
/// suppression, runtime errors from the operand path) rather than message
13+
/// capture. Behavior probed against SQL Server 2025 (2026-05-11).
14+
/// </summary>
15+
[TestClass]
16+
public sealed class PrintStatementTests
17+
{
18+
[TestMethod]
19+
public void Print_StringLiteral_DoesNotThrow()
20+
=> _ = new Simulation().ExecuteNonQuery("print 'hello'");
21+
22+
[TestMethod]
23+
public void Print_Null_DoesNotThrow()
24+
=> _ = new Simulation().ExecuteNonQuery("print null");
25+
26+
[TestMethod]
27+
public void Print_Integer_DoesNotThrow()
28+
=> _ = new Simulation().ExecuteNonQuery("print 42");
29+
30+
[TestMethod]
31+
public void Print_Decimal_DoesNotThrow()
32+
=> _ = new Simulation().ExecuteNonQuery("print 1.5");
33+
34+
[TestMethod]
35+
public void Print_Float_DoesNotThrow()
36+
=> _ = new Simulation().ExecuteNonQuery("print cast(1.5 as float)");
37+
38+
[TestMethod]
39+
public void Print_Variable_DoesNotThrow()
40+
=> _ = new Simulation().ExecuteNonQuery("declare @v varchar(10) = 'hi'; print @v");
41+
42+
[TestMethod]
43+
public void Print_Expression_DoesNotThrow()
44+
=> _ = new Simulation().ExecuteNonQuery("print 5 + 3");
45+
46+
[TestMethod]
47+
public void Print_StringConcat_DoesNotThrow()
48+
=> _ = new Simulation().ExecuteNonQuery("print 'a' + 'b'");
49+
50+
[TestMethod]
51+
public void Print_Case_DoesNotThrow()
52+
=> _ = new Simulation().ExecuteNonQuery("print case when 1=1 then 'y' else 'n' end");
53+
54+
/// <summary>
55+
/// PRINT evaluates the operand normally, so the <c>+</c> operator's
56+
/// int-side promotion still kicks in — <c>'val=' + 5</c> tries to parse
57+
/// <c>'val='</c> as int and raises Msg 245. Probe-confirmed: real SQL
58+
/// Server raises the same Msg 245.
59+
/// </summary>
60+
[TestMethod]
61+
public void Print_StringPlusInt_Msg245()
62+
=> new Simulation().AssertSqlError("print 'val=' + 5", 245);
63+
64+
/// <summary>
65+
/// Probe-confirmed: PRINT resets <c>@@ROWCOUNT</c> to 0 — the next
66+
/// statement reads 0 regardless of whatever the prior statement set.
67+
/// </summary>
68+
[TestMethod]
69+
public void Print_Resets_RowCount_To_Zero()
70+
{
71+
using var reader = new Simulation().ExecuteReader("""
72+
select 1 union all select 2 union all select 3;
73+
print 'between';
74+
select @@rowcount as rc
75+
""");
76+
// Drain the first result set (the SELECT … UNION ALL).
77+
while (reader.Read()) { }
78+
IsTrue(reader.NextResult());
79+
IsTrue(reader.Read());
80+
AreEqual(0, reader.GetInt32(0));
81+
}
82+
83+
// ---- Skip-mode interaction ----
84+
85+
/// <summary>
86+
/// In an un-taken IF branch, PRINT's operand isn't evaluated — so an
87+
/// otherwise-error-raising expression inside the un-taken branch is
88+
/// silently skipped (matches every other statement parser's skip-mode
89+
/// gate).
90+
/// </summary>
91+
[TestMethod]
92+
public void Print_InUntakenIf_OperandNotEvaluated()
93+
=> _ = new Simulation().ExecuteNonQuery("if 1=0 print 'val=' + 5");
94+
95+
[TestMethod]
96+
public void Print_InTakenIf_StillEvaluates()
97+
=> new Simulation().AssertSqlError("if 1=1 print 'val=' + 5", 245);
98+
99+
[TestMethod]
100+
public void Print_InUntakenElse_OperandNotEvaluated()
101+
=> _ = new Simulation().ExecuteNonQuery("if 1=1 select 'taken' else print 'val=' + 5");
102+
103+
[TestMethod]
104+
public void Print_AfterReturn_NotEvaluated()
105+
=> _ = new Simulation().ExecuteNonQuery("return; print 'val=' + 5");
106+
107+
[TestMethod]
108+
public void Print_InBlock_BeforeReturn_Evaluates()
109+
=> new Simulation().AssertSqlError(
110+
"begin print 'val=' + 5; return; end",
111+
245);
112+
113+
/// <summary>
114+
/// PRINT inside a WHILE evaluates each iteration. Verify by including
115+
/// an operand that would always error if reached past the BREAK gate.
116+
/// </summary>
117+
[TestMethod]
118+
public void Print_InWhile_RunsEachIteration()
119+
{
120+
// Loop runs twice, then BREAKs; PRINT inside fires on both runs.
121+
_ = new Simulation().ExecuteNonQuery("""
122+
declare @i int = 0;
123+
while @i < 2
124+
begin
125+
set @i = @i + 1;
126+
print @i;
127+
end
128+
""");
129+
}
130+
131+
// ---- Statement composition / dispatch ----
132+
133+
[TestMethod]
134+
public void Multiple_Prints_AllRun()
135+
=> _ = new Simulation().ExecuteNonQuery("print 'a'; print 'b'; print 'c'");
136+
137+
[TestMethod]
138+
public void Print_Then_Select_SelectReturnsRow()
139+
{
140+
using var reader = new Simulation().ExecuteReader("print 'x'; select 1 as v");
141+
IsTrue(reader.Read());
142+
AreEqual(1, reader.GetInt32(0));
143+
IsFalse(reader.Read());
144+
}
145+
146+
[TestMethod]
147+
public void Select_Then_Print_SelectReturnsRow()
148+
{
149+
using var reader = new Simulation().ExecuteReader("select 1 as v print 'x'");
150+
IsTrue(reader.Read());
151+
AreEqual(1, reader.GetInt32(0));
152+
IsFalse(reader.Read());
153+
}
154+
155+
[TestMethod]
156+
public void Print_BareReturnsAfter_BatchContinues()
157+
{
158+
using var reader = new Simulation().ExecuteReader("print 'before'; select 'after'");
159+
IsTrue(reader.Read());
160+
AreEqual("after", reader.GetString(0));
161+
}
162+
163+
/// <summary>
164+
/// PRINT inside a rolled-back transaction is a no-op for the simulator
165+
/// (output is discarded anyway). The point of this test is to verify
166+
/// PRINT doesn't interact badly with the undo log or transaction state.
167+
/// </summary>
168+
[TestMethod]
169+
public void Print_InRolledBackTransaction_NoStateLeak()
170+
{
171+
var sim = new Simulation();
172+
_ = sim.ExecuteNonQuery("begin tran; print 'inside'; rollback");
173+
// Subsequent statement on a fresh connection should work normally.
174+
AreEqual(1, sim.ExecuteScalar<int>("select 1"));
175+
}
176+
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
423423
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
424424
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
425425
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
426+
or Keyword.Print
426427
} when depth == 0:
427428
goto ExitWhileTokenLoop;
428429

@@ -566,6 +567,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
566567
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
567568
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
568569
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
570+
or Keyword.Print
569571
} when depth == 0:
570572
goto ExitWhileTokenLoop;
571573

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using SqlServerSimulator.Parser;
2+
3+
namespace SqlServerSimulator;
4+
5+
partial class Simulation
6+
{
7+
/// <summary>
8+
/// Parses and runs a <c>PRINT &lt;expression&gt;</c> statement. The
9+
/// expression is parsed unconditionally (advancing the cursor) and
10+
/// evaluated only when not in skip mode so an un-taken <c>IF</c> branch
11+
/// doesn't surface runtime errors from the value computation. Whatever
12+
/// value the expression produces is discarded — the simulator doesn't
13+
/// expose an <c>InfoMessage</c> event on <see cref="SimulatedDbConnection"/>
14+
/// (<c>DbConnection</c> doesn't define one, and a public surface for
15+
/// observing PRINT output isn't justified yet). Probed against SQL Server
16+
/// 2025 (2026-05-11): PRINT resets <c>@@ROWCOUNT</c> to 0 (the dispatcher
17+
/// applies the reset on return); NULL operand emits an empty message;
18+
/// long strings truncate at 8000 / 4000 chars depending on collation —
19+
/// none of which the simulator needs to model when output is discarded.
20+
/// </summary>
21+
/// <remarks>
22+
/// Type validity follows from normal expression evaluation: <c>PRINT 'val=' + 5</c>
23+
/// raises Msg 245 from the <c>+</c> operator (matches probe). One known
24+
/// fidelity gap: real SQL Server raises Msg 1046 ("Subqueries are not
25+
/// allowed in this context") when a scalar subquery appears in the PRINT
26+
/// operand; the simulator silently evaluates it.
27+
/// </remarks>
28+
private static void ParsePrintStatement(BatchContext batch)
29+
{
30+
var context = batch.Parser;
31+
context.MoveNextRequired(); // consume PRINT
32+
var expression = Expression.Parse(context);
33+
if (batch.IsSkipping)
34+
return;
35+
// Evaluate for side effects (surfacing any runtime errors from the
36+
// operand's type / coercion path) and discard the result.
37+
_ = expression.Run(new RuntimeContext(NoColumnResolver, batch));
38+
}
39+
}

SqlServerSimulator/Simulation/Simulation.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@ private IEnumerable<SimulatedStatementOutcome> DispatchOneStatement(BatchContext
322322
ParseReturnStatement(batch);
323323
break;
324324

325+
case ReservedKeyword { Keyword: Keyword.Print }:
326+
ParsePrintStatement(batch);
327+
if (!batch.IsSkipping)
328+
connection.LastStatementRowCount = 0;
329+
break;
330+
325331
case ReservedKeyword { Keyword: Keyword.Begin }:
326332
// Peek the token after BEGIN to disambiguate transaction-start
327333
// (BEGIN TRAN / BEGIN TRANSACTION / BEGIN DISTRIBUTED TRAN) from
@@ -413,7 +419,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
413419
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
414420
or Keyword.Set or Keyword.Declare or Keyword.With or Keyword.If or Keyword.Else
415421
or Keyword.End or Keyword.While or Keyword.Break or Keyword.Continue
416-
or Keyword.Return
422+
or Keyword.Return or Keyword.Print
417423
};
418424

419425
/// <summary>

0 commit comments

Comments
 (0)