Skip to content

Commit 31284f5

Browse files
committed
PRINT message capture via internal SimulatedDbConnection.InfoMessage event — mirrors SqlClient's batch-coalescing semantic (multiple PRINTs in one command join with \n into one event firing), opt-in observability for the existing PRINT statement that previously discarded the operand. Surface kept internal pending the public-API shape decision; SqlServerSimulator.Tests.Internal/PrintInfoMessageTests.cs exercises the wire-up. New SimulatedInfoMessageEventArgs class (internal-sealed-with-public-fields, SSS001-compliant) carries Message + LineNumber + Source — the SqlInfoMessageEventArgs subset that matters without Errors collection or the other catalog fields. BatchContext gains a pendingPrintMessages: List<string>? buffer + firstPrintLine: int slot, populated by AppendPrintMessage(text) lazily (null until the first PRINT — avoids the per-batch allocation for the typical PRINT-less batch); on the dispatch loop's exit from CreateResultSetsForCommand (alongside the existing WriteBackOutputParameters call), FlushPrintMessages() joins the buffer with \n and raises Connection.InfoMessage once with the first contributing PRINT's StatementContext.StartLine as the event's LineNumber. The probe-confirmed NULL-operand surprise (PRINT NULL → message string " ", single space, not empty) is preserved in FormatPrintValue's null branch; string-typed values pass through AsString verbatim (preserving embedded CR/LF for the 'line1\nline2' case); other types coerce to varchar(8000) via SqlValue.CoerceTo — the same path CAST(value AS VARCHAR(8000)) takes, which differs from SQL Server's PRINT-specific style 0 conventions (datetime / money formatting divergence documented as known fidelity gap; string-based PRINT is the dominant pattern so the gap is rarely hit in practice). Skip-mode IF correctly suppresses the buffer append because the existing if (batch.IsSkipping) return; guard in ParsePrintStatement runs before AppendPrintMessage. PRINT inside BEGIN TRAN / ROLLBACK still delivers (info messages aren't transactional — matches SqlClient probe). 16 new internal tests cover: simple string, NULL → single space, 'a' + null → single space (ANSI string-concat-NULL → NULL), integer formatting, embedded-newline preservation, no-PRINT-no-event, skip-mode IF suppression, ELSE-branch firing, WHILE-loop coalescing across iterations, transaction-rollback delivery, TRY/CATCH cross-block coalescing, multi-command separation (one event per ExecuteNonQuery), line-number = first PRINT's line, Source = "SqlServerSimulator", and null-subscriber no-throw. CLAUDE.md "Not modeled" entry rewritten — InfoMessage surface is internal-shipped; the *public* event remains deferred pending shape design (mirror SqlClient exactly? trim further? add DbConnection-compatible extension method?). Documented fidelity gaps preserved: Msg 1046 subquery-in-operand rejection still silently evaluates; datetime / money formatting routes through CoerceTo rather than PRINT style 0; 8000-byte / 4000-char output truncation not enforced.
1 parent 8f65789 commit 31284f5

7 files changed

Lines changed: 365 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
176176
- **`CREATE SCHEMA sys` / `INFORMATION_SCHEMA`** — raises Msg 2760 (matching real SQL Server). The schemas themselves exist as catalog-view hosts (`select * from sys.tables` / `select * from INFORMATION_SCHEMA.COLUMNS` work); legacy bare 1-part system-table access (`select * from systypes`) also still works.
177177
- T-SQL `GOTO` / labels — `IF` / `BEGIN…END` / `WHILE` / `BREAK` / `CONTINUE` / `RETURN` (bare + UDF-body and stored-procedure-body value form) ship; unconditional jumps don't.
178178
- CLR functions, INSTEAD OF UPDATE / DELETE on *non-updatable* views (INSTEAD OF INSERT on any view ships; INSTEAD OF UPDATE / DELETE on updatable single-base views ships). DDL / logon triggers also unmodeled. Scalar UDFs, inline TVFs, multi-statement TVFs (`RETURNS @r TABLE (cols) AS BEGIN ... END` — EF Core `HasDbFunction` end-to-end), views, DML-through-views (single-source updatable shape), stored procedures (including CREATE / ALTER / DROP, EXEC with input/output/default params, `@rc = EXEC` return-code capture, `CommandType.StoredProcedure`, `EXEC (@sql)` and `sp_executesql` dynamic SQL), user-defined table types + table-valued parameters (CREATE TYPE … AS TABLE / DROP TYPE / DECLARE @t MyType / READONLY proc params / EXEC with TVP arg / ADO.NET Structured parameter via the `DbParameter.TypeName` C# 14 extension property + DataTable/IDataReader sources), sequence objects (CREATE / ALTER / DROP SEQUENCE / NEXT VALUE FOR / sys.sequences — supports EF Core HiLo identity strategy end-to-end), and DML triggers (CREATE / ALTER / CREATE OR ALTER / DROP TRIGGER, FOR-as-AFTER synonym, AFTER on heap tables + INSTEAD OF on heap tables / views, multi-action INSERT,UPDATE,DELETE, INSERTED/DELETED pseudo-tables, multiple AFTER triggers per parent / one INSTEAD OF per action per target (Msg 2111), DISABLE/ENABLE TRIGGER, TRIGGER_NESTLEVEL(), direct-recursion suppression matching RECURSIVE_TRIGGERS OFF, sys.triggers + sys.objects integration, trigger-error rolls back firing DML, MERGE per-action routing through INSTEAD OF, DROP TABLE / DROP VIEW cascade-drops attached triggers) ship (see `docs/claude/programmable.md`, `docs/claude/table-valued-parameters.md`, `docs/claude/sequences.md`, and `docs/claude/triggers.md`). JOIN-view single-base-table UPDATE/DELETE, OUTPUT through views, and multi-source alias-form UPDATE/DELETE through views are deferred (Msg 4405 or `NotSupportedException` at the DML site). `BEGIN ATOMIC` / `BEGIN DISTRIBUTED TRANSACTION` raise `NotSupportedException` at dispatch. Value-form `RETURN N` is legal inside a scalar-UDF body and a stored-procedure body; bare batch / dynamic-SQL scope raises Msg 178. TRY/CATCH + THROW + RAISERROR (printf-style `%s %d/%i %u %o %x %X %% %ld %I64d` with width / precision / left-align / zero-pad; severity routing 0-10 informational vs 11-18 catchable; `WITH SETERROR`/`NOWAIT` ship, `WITH LOG` uniformly raises Msg 2778; numeric `msg_id` raises Msg 2732 for 50000 / `< 13000` and Msg 18054 otherwise — `sys.messages` registry / `sp_addmessage` not modeled) + live `@@ERROR` + `ERROR_*()` functions ship (see `docs/claude/control-flow.md`).
179-
- **`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.
179+
- **Public `InfoMessage` event** — `SimulatedDbConnection.InfoMessage` ships as an `internal` event (with `internal SimulatedInfoMessageEventArgs` carrying `Message`, `LineNumber`, `Source`) and a `BatchContext`-side buffer that coalesces multiple `PRINT`s per command into one firing (joined with `\n`, line number = first contributing `PRINT`'s line). The internal surface is exercised through `SqlServerSimulator.Tests.Internal/PrintInfoMessageTests.cs`. Going *public* — exposing the event on the `SimulatedDbConnection` consumer surface for application use — is deferred pending the public-API shape decision (mirror SqlClient's `SqlInfoMessageEventArgs` exactly? trim to a minimal shape? add a `DbConnection`-compatible extension method instead of a typed event?). Subquery-in-operand still silently evaluates instead of raising Msg 1046 ("Subqueries are not allowed in this context"); non-string-value formatting routes through `SqlValue.CoerceTo(varchar(8000))` rather than SQL Server's PRINT-specific style 0 conventions (datetime → ISO instead of `"May 14 2026 12:00AM"`; money → `F4` instead of 2-decimal). The 8000-byte ANSI / 4000-character Unicode PRINT truncation isn't enforced — long strings pass through verbatim.
180180
- **`ALTER TABLE` grammar beyond SET / ADD / DROP / ALTER COLUMN / CHECK / NOCHECK CONSTRAINT** — `SET (SYSTEM_VERSIONING = OFF)` (see [`docs/claude/temporal-tables.md`](docs/claude/temporal-tables.md)), `[WITH CHECK | WITH NOCHECK] ADD [CONSTRAINT name] (PRIMARY KEY | UNIQUE | FOREIGN KEY | CHECK | DEFAULT) …`, `DROP CONSTRAINT [IF EXISTS] name [, …]`, `[WITH CHECK | WITH NOCHECK] (CHECK | NOCHECK) CONSTRAINT (ALL | name [, …])` (trust toggling for bulk imports — sets / clears `is_disabled` + `is_not_trusted`), `ADD [COLUMN] col TYPE [, …]` (multi-column add — inline DEFAULT / IDENTITY / CHECK / FK / computed all supported; Msg 4901 on NOT NULL without DEFAULT on non-empty table; eager row rewrite with NULL backfill for nullable adds and constant-snapshot DEFAULT for NOT NULL), `DROP COLUMN [IF EXISTS] col [, …]` (Msg 4924 missing column, Msg 5074 dependency rejection with multi-blocker enumeration across PK/UQ/FK/CHECK/DEFAULT/index, atomic multi-drop), and `ALTER COLUMN col TYPE[(prec[,scale])] [COLLATE coll] [NULL|NOT NULL]` (single-column shape — type widening / narrowing routed through `SqlValue.CoerceTo` so Msg 220 / 245 / 241 / 8115 / 2628 surface verbatim; nullability flip raises Msg 515 on existing NULL; Msg 4928 for COMPUTED / rowversion; Msg 5074 multi-blocker enumeration across PK / UQ / FK both directions / computed-column dependencies / type-changing index references — length widening within the same `SqlType` family allowed under an index; COLLATE clause parse-accepted then ignored; identity / DEFAULT / inline CHECK preserve through the column instance swap) all ship (see [`docs/claude/alter-table.md`](docs/claude/alter-table.md)). DROP PERIOD FOR SYSTEM_TIME, REBUILD, SWITCH PARTITION, the SET-versioning-on direction, the `ALTER COLUMN col ADD/DROP {PERSISTED|MASKED|ROWGUIDCOL|SPARSE}` sub-clause forms, ALTER COLUMN on an identity column targeting a non-integer type, and every other shape raise `NotSupportedException`. Multi-constraint ADD (`ADD CONSTRAINT pk1 PK (a), CONSTRAINT fk1 FK …`) also raises.
181181
- `hierarchyid`, `geography`, `geometry`.
182182

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Internal-only tests for the <see cref="SimulatedDbConnection.InfoMessage"/>
7+
/// event surface that delivers buffered <c>PRINT</c> output. The event itself
8+
/// is internal pending a public-API decision (DbConnection has no equivalent
9+
/// in the base class); these tests pin the wire-up so that consumer-shape
10+
/// changes catch a failure rather than silent behavior shift.
11+
/// </summary>
12+
/// <remarks>
13+
/// Probed against SQL Server 2025 (2026-05-14):
14+
/// <list type="bullet">
15+
/// <item>Multiple <c>PRINT</c>s in one batch coalesce into one event with
16+
/// messages joined by <c>\n</c>.</item>
17+
/// <item>NULL operand emits a single space, not empty.</item>
18+
/// <item>Skip-mode IF suppresses the PRINT.</item>
19+
/// <item>Event fires once per <c>ExecuteNonQuery</c> command, after all
20+
/// statements complete (even when later statements roll back).</item>
21+
/// </list>
22+
/// </remarks>
23+
[TestClass]
24+
public sealed class PrintInfoMessageTests
25+
{
26+
private static (Simulation Sim, System.Data.Common.DbConnection Conn, List<SimulatedInfoMessageEventArgs> Captured) NewWithCapture()
27+
{
28+
var sim = new Simulation();
29+
var conn = sim.CreateDbConnection();
30+
conn.Open();
31+
var captured = new List<SimulatedInfoMessageEventArgs>();
32+
((SimulatedDbConnection)conn).InfoMessage += (_, e) => captured.Add(e);
33+
return (sim, conn, captured);
34+
}
35+
36+
private static int RunNonQuery(System.Data.Common.DbConnection conn, string commandText)
37+
{
38+
using var cmd = conn.CreateCommand();
39+
cmd.CommandText = commandText;
40+
return cmd.ExecuteNonQuery();
41+
}
42+
43+
[TestMethod]
44+
public void Print_StringLiteral_FiresOneMessage()
45+
{
46+
var (_, conn, captured) = NewWithCapture();
47+
_ = RunNonQuery(conn, "print 'hello'");
48+
HasCount(1, captured);
49+
AreEqual("hello", captured[0].Message);
50+
}
51+
52+
[TestMethod]
53+
public void Print_TwoStatements_CoalesceWithLineFeed()
54+
{
55+
var (_, conn, captured) = NewWithCapture();
56+
_ = RunNonQuery(conn, "print 'first'; print 'second'");
57+
HasCount(1, captured);
58+
AreEqual("first\nsecond", captured[0].Message);
59+
}
60+
61+
[TestMethod]
62+
public void Print_NullOperand_EmitsSingleSpace()
63+
{
64+
var (_, conn, captured) = NewWithCapture();
65+
_ = RunNonQuery(conn, "print null");
66+
HasCount(1, captured);
67+
AreEqual(" ", captured[0].Message);
68+
}
69+
70+
[TestMethod]
71+
public void Print_StringPlusNull_EmitsSingleSpace()
72+
{
73+
// 'a' + NULL collapses to NULL under ANSI string-concat NULL semantics,
74+
// and the NULL-operand-formats-as-single-space rule kicks in.
75+
var (_, conn, captured) = NewWithCapture();
76+
_ = RunNonQuery(conn, "print 'a' + null");
77+
HasCount(1, captured);
78+
AreEqual(" ", captured[0].Message);
79+
}
80+
81+
[TestMethod]
82+
public void Print_Integer_FormatsAsInvariantString()
83+
{
84+
var (_, conn, captured) = NewWithCapture();
85+
_ = RunNonQuery(conn, "print 42");
86+
HasCount(1, captured);
87+
AreEqual("42", captured[0].Message);
88+
}
89+
90+
[TestMethod]
91+
public void Print_StringLiteral_PreservesEmbeddedNewlines()
92+
{
93+
// T-SQL string literals can carry raw newlines; the captured message
94+
// should preserve them verbatim. (The `CHAR(13) + CHAR(10)` form used
95+
// by some SQL idioms isn't reachable here — CHAR() isn't modeled —
96+
// but the literal-newline form covers the same observation.)
97+
var (_, conn, captured) = NewWithCapture();
98+
_ = RunNonQuery(conn, "print 'line1\nline2'");
99+
HasCount(1, captured);
100+
AreEqual("line1\nline2", captured[0].Message);
101+
}
102+
103+
[TestMethod]
104+
public void Print_NoStatements_NoEventFires()
105+
{
106+
var (_, conn, captured) = NewWithCapture();
107+
_ = RunNonQuery(conn, "select 1");
108+
IsEmpty(captured);
109+
}
110+
111+
[TestMethod]
112+
public void Print_SkipModeIf_SuppressesEvent()
113+
{
114+
var (_, conn, captured) = NewWithCapture();
115+
_ = RunNonQuery(conn, "if 1 = 0 print 'unreachable'");
116+
IsEmpty(captured);
117+
}
118+
119+
[TestMethod]
120+
public void Print_ElseBranchTaken_FiresMessage()
121+
{
122+
var (_, conn, captured) = NewWithCapture();
123+
_ = RunNonQuery(conn, "if 1 = 0 print 'no' else print 'yes'");
124+
HasCount(1, captured);
125+
AreEqual("yes", captured[0].Message);
126+
}
127+
128+
[TestMethod]
129+
public void Print_InsideWhile_CoalescesAllIterations()
130+
{
131+
var (_, conn, captured) = NewWithCapture();
132+
_ = RunNonQuery(conn, """
133+
declare @i int = 0;
134+
while @i < 3
135+
begin
136+
print 'iter ' + cast(@i as varchar(5));
137+
set @i = @i + 1;
138+
end
139+
""");
140+
HasCount(1, captured);
141+
AreEqual("iter 0\niter 1\niter 2", captured[0].Message);
142+
}
143+
144+
[TestMethod]
145+
public void Print_InTransactionRollback_StillDelivers()
146+
{
147+
// Info messages aren't transactional — probe-confirmed PRINT inside
148+
// BEGIN TRAN / ROLLBACK still fires.
149+
var (_, conn, captured) = NewWithCapture();
150+
_ = RunNonQuery(conn, """
151+
begin tran;
152+
print 'inside';
153+
rollback
154+
""");
155+
HasCount(1, captured);
156+
AreEqual("inside", captured[0].Message);
157+
}
158+
159+
[TestMethod]
160+
public void Print_BeforeAndAfterTryCatch_BothCoalesced()
161+
{
162+
var (_, conn, captured) = NewWithCapture();
163+
_ = RunNonQuery(conn, """
164+
begin try
165+
print 'before';
166+
throw 50000, 'oops', 1;
167+
end try
168+
begin catch
169+
print 'caught';
170+
end catch
171+
""");
172+
HasCount(1, captured);
173+
AreEqual("before\ncaught", captured[0].Message);
174+
}
175+
176+
[TestMethod]
177+
public void Print_TwoSeparateCommands_FireTwoEvents()
178+
{
179+
// Each ExecuteNonQuery is its own batch; events are per-batch, not
180+
// per-connection-lifetime.
181+
var (_, conn, captured) = NewWithCapture();
182+
_ = RunNonQuery(conn, "print 'first command'");
183+
_ = RunNonQuery(conn, "print 'second command'");
184+
HasCount(2, captured);
185+
AreEqual("first command", captured[0].Message);
186+
AreEqual("second command", captured[1].Message);
187+
}
188+
189+
[TestMethod]
190+
public void Print_LineNumber_PointsToFirstPrint()
191+
{
192+
var (_, conn, captured) = NewWithCapture();
193+
// Three lines, first PRINT on line 2 (after the comment + select).
194+
_ = RunNonQuery(conn, """
195+
select 1;
196+
print 'first';
197+
print 'second'
198+
""");
199+
HasCount(1, captured);
200+
AreEqual(2, captured[0].LineNumber);
201+
}
202+
203+
[TestMethod]
204+
public void Print_Source_IsSimulatorIdentifier()
205+
{
206+
var (_, conn, captured) = NewWithCapture();
207+
_ = RunNonQuery(conn, "print 'hi'");
208+
AreEqual("SqlServerSimulator", captured[0].Source);
209+
}
210+
211+
[TestMethod]
212+
public void Print_NoSubscriber_DoesNotThrow()
213+
{
214+
// Event delivery is null-safe — running PRINT without a subscriber
215+
// is the common ad-hoc case.
216+
var sim = new Simulation();
217+
using var conn = sim.CreateDbConnection();
218+
conn.Open();
219+
using var cmd = conn.CreateCommand();
220+
cmd.CommandText = "print 'no listener'";
221+
_ = cmd.ExecuteNonQuery();
222+
}
223+
}

SqlServerSimulator/Parser/BatchContext.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,63 @@ internal sealed class BatchContext
5555
/// </summary>
5656
public readonly StatementContext CurrentStatement = new();
5757

58+
/// <summary>
59+
/// Buffer of <c>PRINT</c>-emitted strings collected across this batch.
60+
/// Probe-confirmed coalescing semantic: multiple <c>PRINT</c> statements
61+
/// in one command join with <c>\n</c> separators into a single
62+
/// <see cref="SimulatedDbConnection.InfoMessage"/> firing at end of
63+
/// dispatch. Null when no PRINT has fired yet (avoids the per-batch
64+
/// allocation for the typical PRINT-less batch).
65+
/// </summary>
66+
private List<string>? pendingPrintMessages;
67+
68+
/// <summary>
69+
/// 1-based line of the first <c>PRINT</c> statement in this batch —
70+
/// captured at the moment the first message is buffered. SqlClient
71+
/// probe-confirmed: the coalesced <c>InfoMessage</c> event carries
72+
/// the first contributing statement's line, even when later <c>PRINT</c>s
73+
/// in the same batch live on different lines.
74+
/// </summary>
75+
private int firstPrintLine;
76+
77+
/// <summary>
78+
/// Buffers a <c>PRINT</c>-emitted string against this batch's pending
79+
/// output list. Caller has already formatted the operand value into its
80+
/// display string (NULL → single space per probe). Skipped-IF / loop-
81+
/// control suppression is decided by the caller (<see cref="IsSkipping"/>),
82+
/// not here.
83+
/// </summary>
84+
internal void AppendPrintMessage(string text)
85+
{
86+
if (this.pendingPrintMessages is null)
87+
{
88+
this.pendingPrintMessages = [];
89+
this.firstPrintLine = this.CurrentStatement.StartLine;
90+
}
91+
this.pendingPrintMessages.Add(text);
92+
}
93+
94+
/// <summary>
95+
/// If any <c>PRINT</c> statements buffered output during this batch,
96+
/// delivers them to <see cref="SimulatedDbConnection.InfoMessage"/>
97+
/// subscribers as a single event (messages joined with <c>\n</c>,
98+
/// <see cref="SimulatedInfoMessageEventArgs.LineNumber"/> set to the
99+
/// first contributing PRINT's line). No-op when the buffer is empty.
100+
/// Called by <see cref="Simulation.CreateResultSetsForCommand"/> after
101+
/// dispatch completes.
102+
/// </summary>
103+
internal void FlushPrintMessages()
104+
{
105+
if (this.pendingPrintMessages is not { Count: > 0 } list)
106+
return;
107+
var joined = string.Join('\n', list);
108+
this.Connection.RaiseInfoMessage(new SimulatedInfoMessageEventArgs(
109+
joined,
110+
this.firstPrintLine,
111+
source: "SqlServerSimulator"));
112+
list.Clear();
113+
}
114+
58115
/// <summary>
59116
/// Raw IF-skip flag: true while the dispatch loop is walking through an
60117
/// un-taken IF branch. The <see cref="IsSkipping"/> property OR's this

SqlServerSimulator/SimulatedDbConnection.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,25 @@ internal bool IsVerboseTruncationActive() =>
154154
?? (this.TraceFlags.Contains(460)
155155
|| this.CurrentDatabase.CompatibilityLevel >= CompatibilityLevel.Sql160);
156156

157+
/// <summary>
158+
/// Fires once per batch when the batch contained at least one
159+
/// <c>PRINT</c> statement that produced output (the un-taken-IF /
160+
/// skip-mode path doesn't fire). Multiple <c>PRINT</c> calls in the
161+
/// batch coalesce into a single event with the messages joined by
162+
/// <c>\n</c> — matches SqlClient's <c>InfoMessage</c> probe behavior.
163+
/// Internal-only until the consumer-facing event shape is settled
164+
/// (<c>DbConnection</c> has no equivalent event in the base
165+
/// class — going public means adding a new public-API surface).
166+
/// </summary>
167+
internal event EventHandler<SimulatedInfoMessageEventArgs>? InfoMessage;
168+
169+
/// <summary>
170+
/// Delivers a buffered <c>PRINT</c> batch to <see cref="InfoMessage"/>
171+
/// subscribers. Called from <see cref="Parser.BatchContext.FlushPrintMessages"/>
172+
/// at the end of each command's dispatch.
173+
/// </summary>
174+
internal void RaiseInfoMessage(SimulatedInfoMessageEventArgs args) => this.InfoMessage?.Invoke(this, args);
175+
157176
[AllowNull]
158177
public override string ConnectionString { get => ""; set => throw new NotImplementedException(); }
159178

0 commit comments

Comments
 (0)