Skip to content

Commit e6bf0ce

Browse files
committed
Errors carry real line numbers, server names, and procedure context: line/procedure stamp ambiently at the dispatch catch boundary, proc-body errors report CREATE-relative lines with a schema-qualified Procedure via BodyLineOffset threading, THROW re-raise preserves the original diagnostics, ERROR_LINE/ERROR_PROCEDURE match the exception, and Server mirrors real SqlClient's split: the in-process exception reports the connection data source while the TDS ERROR/INFO tokens carry @@ServerName, which SqlClient overrides client-side exactly like the real driver.
1 parent f11f6d8 commit e6bf0ce

21 files changed

Lines changed: 499 additions & 31 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
130130
- **`PIVOT` / `UNPIVOT`** — PIVOT desugars to grouped conditional aggregation (implicit grouping = inner columns minus FOR + aggregate-arg), UNPIVOT is a NULL-skipping unfold; both attach as a postfix FROM-source wrapper on the derived-table `LateralPlan` seam → [`pivot.md`](docs/claude/pivot.md).
131131
- **UPDATE / DELETE / INSERT…SELECT / SELECT…INTO / rowversion** (`@@DBTS` / `MIN_ACTIVE_ROWVERSION`) **/ identity helpers** (`@@IDENTITY` / `SCOPE_IDENTITY` / `IDENT_CURRENT`/`_INCR`/`_SEED`) **/ `@@ROWCOUNT` / `ROWCOUNT_BIG` / OUTPUT / MERGE**[`dml.md`](docs/claude/dml.md).
132132
- **Variables, control flow (IF/WHILE/BREAK/CONTINUE/RETURN), TRY/CATCH+THROW+ERROR_*, `@@ERROR` / `@@TRANCOUNT` / `XACT_STATE`, PRINT, WAITFOR**[`control-flow.md`](docs/claude/control-flow.md).
133+
- **Error diagnostics**`SimulatedSqlException` / `SimulatedError` line number (statement-start for runtime/bind, token line for syntax, CREATE-relative for proc bodies), `Server` (= data source), schema-qualified `Procedure`; `ERROR_LINE` / `ERROR_PROCEDURE` parity; TDS ERROR/INFO token server field (`SIMULATED`) → [`errors.md`](docs/claude/errors.md).
133134
- **Cursors** (`DECLARE … CURSOR` / `OPEN` / `FETCH` / `CLOSE` / `DEALLOCATE`, STATIC / KEYSET / DYNAMIC sensitivity, scroll fetches, `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS`, `WHERE CURRENT OF`; GLOBAL / LOCAL scope + scope-aware resolution, cursor variables (`DECLARE @c CURSOR` / `SET @c = CURSOR …` refcounted, cursor OUTPUT proc params), `FOR UPDATE OF` gating (Msg 16932), SCROLL_LOCKS cursor-scoped U locks + OPTIMISTIC conflict detection (Msg 16947/16934), TYPE_WARNING (Msg 16956)) → [`cursors.md`](docs/claude/cursors.md).
134135
- **CTE shapes / recursive-CTE error handling**[`ctes.md`](docs/claude/ctes.md).
135136
- **JSON** (JSON_VALUE / JSON_QUERY / JSON_MODIFY / JSON_OBJECT / JSON_ARRAY / JSON_OBJECTAGG / JSON_ARRAYAGG / JSON_PATH_EXISTS / ISJSON / OPENJSON) → [`json.md`](docs/claude/json.md).

SqlServerSimulator.Tests.SqlClient/ErrorSurfaceTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ public async Task AfterError_SameConnection_StillExecutes()
9797
AreEqual(1, await ok.ExecuteScalarAsync(TestContext.CancellationToken));
9898
}
9999

100+
[TestMethod]
101+
public async Task Error_CarriesBatchRelativeLineNumber()
102+
{
103+
var simulation = new Simulation();
104+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
105+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
106+
107+
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
108+
{
109+
await using var command = new SqlCommand("declare @x int\nset @x = 1 / 0", connection);
110+
_ = await command.ExecuteNonQueryAsync(TestContext.CancellationToken);
111+
});
112+
113+
// The failing SET is the batch's second line; the wire ERROR token
114+
// carries that line and SqlClient surfaces it on LineNumber.
115+
AreEqual(2, ex.LineNumber);
116+
AreEqual(2, ex.Errors[0].LineNumber);
117+
}
118+
100119
[TestMethod]
101120
public async Task MissingTable_Number208()
102121
{

SqlServerSimulator.Tests.SqlClient/InfoMessageTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ public async Task RaiseError_LowSeverity_ArrivesAsInfoMessage()
5252
Contains("warn", messages);
5353
}
5454

55+
[TestMethod]
56+
public async Task Print_InfoToken_CarriesBatchRelativeLineNumber()
57+
{
58+
var simulation = new Simulation();
59+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
60+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
61+
var lines = new List<int>();
62+
connection.InfoMessage += (_, e) =>
63+
{
64+
foreach (SqlError error in e.Errors)
65+
lines.Add(error.LineNumber);
66+
};
67+
68+
await using var command = new SqlCommand("select 1\nprint 'hello'", connection);
69+
await using (var reader = await command.ExecuteReaderAsync(TestContext.CancellationToken))
70+
{
71+
while (await reader.NextResultAsync(TestContext.CancellationToken))
72+
{
73+
}
74+
}
75+
76+
// PRINT is the batch's second line; the INFO token carries that line.
77+
AreEqual(2, lines.Single());
78+
}
79+
5580
// The go-sqlcmd shakedown (2026-07-14) exposed a token-stream desync when
5681
// an INFO token shares a SQLBatch response with a result set: SqlClient
5782
// hung until command timeout on every mixed shape below (go-mssqldb
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
2+
3+
namespace SqlServerSimulator;
4+
5+
/// <summary>
6+
/// Line-number, server-name, and procedure attribution on
7+
/// <see cref="SimulatedSqlException"/>, mirroring the values real SqlClient
8+
/// surfaces (probe-confirmed against SQL Server 2025, 2026-07-18). Semantics:
9+
/// runtime / bind errors report the failing statement's start line, syntax
10+
/// errors (severity 15) report the offending token's line, procedure-body
11+
/// errors report a line relative to the whole <c>CREATE</c> statement plus the
12+
/// schema-qualified procedure name, and dynamic SQL reports a line relative to
13+
/// the dynamic batch. <see cref="SimulatedError.Server"/> matches the
14+
/// connection data source (real <c>SqlException.Server</c> carries the data
15+
/// source, not <c>@@SERVERNAME</c>). See <c>docs/claude/errors.md</c>.
16+
/// </summary>
17+
[TestClass]
18+
public sealed class ErrorDiagnosticsTests
19+
{
20+
[TestMethod]
21+
public void RuntimeError_ReportsFailingStatementStartLine()
22+
=> AreEqual(2, new Simulation().AssertSqlError("declare @x int\nset @x = 1 / 0", 8134).LineNumber);
23+
24+
/// <summary>
25+
/// The SET spans lines 2-3; the divide-by-zero token sits on line 3, but
26+
/// the reported line is the statement start (line 2), matching real.
27+
/// </summary>
28+
[TestMethod]
29+
public void RuntimeError_SpanningLines_ReportsStatementStartNotExpressionLine()
30+
=> AreEqual(2, new Simulation().AssertSqlError("declare @x int\nset @x =\n 1 / 0", 8134).LineNumber);
31+
32+
[TestMethod]
33+
public void BindError_MissingTable_ReportsStatementStartLine()
34+
=> AreEqual(2, new Simulation().AssertSqlError("declare @x int\nselect * from no_such_table_xyz", 208).LineNumber);
35+
36+
[TestMethod]
37+
public void ConstraintViolation_ReportsFailingInsertLine()
38+
{
39+
var sim = new Simulation();
40+
_ = sim.ExecuteNonQuery("create table t (id int primary key); insert t values (1)");
41+
AreEqual(2, sim.AssertSqlError("declare @x int\ninsert t values (1)", 2627).LineNumber);
42+
}
43+
44+
/// <summary>
45+
/// Severity-15 syntax errors report the token line, not the statement
46+
/// start: the batch's first statement is on line 1, the bad token on 2.
47+
/// </summary>
48+
[TestMethod]
49+
public void SyntaxError_ReportsOffendingTokenLine()
50+
=> AreEqual(2, new Simulation().AssertSqlError("declare @x int\nselect )", 102).LineNumber);
51+
52+
[TestMethod]
53+
public void Error_ServerMatchesConnectionDataSource()
54+
{
55+
var sim = new Simulation();
56+
using var connection = sim.CreateDbConnection();
57+
var ex = sim.AssertSqlError("select 1 / 0", 8134);
58+
AreEqual(connection.DataSource, ex.Server);
59+
AreEqual(connection.DataSource, ex.Errors[0].Server);
60+
}
61+
62+
[TestMethod]
63+
public void TopLevelError_HasNoProcedure()
64+
=> IsEmpty(new Simulation().AssertSqlError("select 1 / 0", 8134).Procedure);
65+
66+
[TestMethod]
67+
public void ProcedureBodyError_ReportsCreateRelativeLineAndQualifiedName()
68+
{
69+
var sim = new Simulation();
70+
sim.ExecuteBatches("create procedure dbo.p_boom as\nbegin\n declare @x int\n set @x = 1 / 0\nend");
71+
var ex = sim.AssertSqlError("exec dbo.p_boom", 8134);
72+
// CREATE line 1, BEGIN line 2, DECLARE line 3, failing SET line 4.
73+
AreEqual(4, ex.LineNumber);
74+
AreEqual("dbo.p_boom", ex.Procedure);
75+
}
76+
77+
[TestMethod]
78+
public void NestedProcedureError_ReportsInnermostFrame()
79+
{
80+
var sim = new Simulation();
81+
sim.ExecuteBatches(
82+
"create procedure dbo.inner_boom as\nbegin\n declare @x int\n set @x = 1 / 0\nend",
83+
"create procedure dbo.outer_p as\nbegin\n exec dbo.inner_boom\nend");
84+
var ex = sim.AssertSqlError("exec dbo.outer_p", 8134);
85+
AreEqual("dbo.inner_boom", ex.Procedure);
86+
AreEqual(4, ex.LineNumber);
87+
}
88+
89+
[TestMethod]
90+
public void DynamicSql_ReportsLineRelativeToDynamicBatch_NoProcedure()
91+
{
92+
var ex = new Simulation().AssertSqlError("exec('declare @x int\nset @x = 1 / 0')", 8134);
93+
AreEqual(2, ex.LineNumber);
94+
IsEmpty(ex.Procedure);
95+
}
96+
97+
[TestMethod]
98+
public void ThrowValueForm_ReportsThrowStatementLine()
99+
{
100+
var ex = new Simulation().AssertSqlError("declare @x int\nthrow 51000, N'boom', 1", 51000);
101+
AreEqual(2, ex.LineNumber);
102+
}
103+
104+
[TestMethod]
105+
public void ThrowReRaise_PreservesOriginalErrorLine()
106+
{
107+
// A bare THROW; re-raising a divide-by-zero from line 3 preserves that
108+
// line rather than reporting the THROW's own line (probe-confirmed).
109+
var ex = new Simulation().AssertSqlError(
110+
"declare @x int\nbegin try\n set @x = 1 / 0\nend try\nbegin catch\n throw\nend catch",
111+
8134);
112+
AreEqual(3, ex.LineNumber);
113+
}
114+
}

SqlServerSimulator.Tests/TryCatchTests.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,25 @@ public void ErrorLine_SingleLineBatch_Returns1()
7474
=> AreEqual(1, new Simulation().ExecuteScalar(
7575
"begin try select cast('abc' as int) end try begin catch select error_line() end catch"));
7676

77-
// ERROR_LINE multi-line fidelity: line tracking is approximate (uses
78-
// the dispatching statement's start-line token); some constant-folded
79-
// expressions can throw at a position the dispatch wrap doesn't see, so
80-
// the line surfaces as 1. Acceptable approximation today — fix needs
81-
// per-error line carry-through on SimulatedSqlException.
77+
/// <summary>
78+
/// The failing SET is on batch line 3; ERROR_LINE() reports it, not the
79+
/// TRY / batch start.
80+
/// </summary>
81+
[TestMethod]
82+
public void ErrorLine_MultiLineBatch_ReportsFailingStatementLine()
83+
=> AreEqual(3, new Simulation().ExecuteScalar(
84+
"declare @x int\nbegin try\nset @x = cast('abc' as int)\nend try begin catch select error_line() end catch"));
85+
86+
[TestMethod]
87+
public void ErrorLine_MatchesExceptionLineNumber()
88+
{
89+
// ERROR_LINE() reports the same line the surfaced exception carries.
90+
var sim = new Simulation();
91+
var caught = sim.AssertSqlError("declare @x int\nset @x = cast('abc' as int)", 245);
92+
AreEqual(2, caught.LineNumber);
93+
AreEqual(2, sim.ExecuteScalar(
94+
"declare @x int\nbegin try set @x = cast('abc' as int) end try begin catch select error_line() end catch"));
95+
}
8296

8397
[TestMethod]
8498
public void ErrorProcedure_OutsideProc_ReturnsNull()

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,22 @@ internal static SimulatedSqlException ThrowMustBeInsideCatch() =>
287287
internal static SimulatedSqlException ThrowRaised(int number, string message, byte state) =>
288288
new(message, number, 16, state) { TerminatesBatch = true };
289289

290+
/// <summary>
291+
/// The no-argument <c>THROW;</c> re-raise form, reconstructed from the
292+
/// enclosing CATCH's in-flight error. Unlike the value form, real SQL
293+
/// Server preserves the <em>original</em> error's line and procedure
294+
/// (probe-confirmed: a <c>THROW;</c> re-raising a divide-by-zero from line
295+
/// 2 still reports line 2, not the <c>THROW</c> statement's line), so this
296+
/// pre-stamps them and marks the exception resolved to keep the enclosing
297+
/// dispatch frame from overwriting with the <c>THROW</c> statement's line.
298+
/// </summary>
299+
internal static SimulatedSqlException ThrowReRaised(int number, string message, byte state, int line, string? procedure)
300+
{
301+
var exception = new SimulatedSqlException(message, number, 16, state) { TerminatesBatch = true };
302+
exception.PreserveDiagnostics(line, procedure);
303+
return exception;
304+
}
305+
290306
/// <summary>
291307
/// Mimics SQL Server error 2787: a <c>RAISERROR</c> format string contains
292308
/// a specifier the runtime doesn't accept. Real SQL Server's RAISERROR

SqlServerSimulator/Errors/SimulatedSqlException.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public sealed partial class SimulatedSqlException : DbException
3636
private const string SourceName = "Core Microsoft SqlClient Data Provider";
3737

3838
private SimulatedSqlException(string message, int number, byte @class, byte state)
39-
: this(message, new SimulatedError(@class, lineNumber: 0, message, number, procedure: "", server: "", source: SourceName, state))
39+
: this(message, new SimulatedError(@class, lineNumber: 0, message, number, procedure: "", server: SimulatedDbConnection.DataSourceName, source: SourceName, state))
4040
{
4141
}
4242

@@ -49,7 +49,7 @@ private SimulatedSqlException(string message, params ReadOnlySpan<SimulatedError
4949
SimulatedError first;
5050
if (errors.Length == 0)
5151
{
52-
first = new SimulatedError(@class: 0, lineNumber: 0, base.Message, number: 0, procedure: "", server: "", source: SourceName, state: 0);
52+
first = new SimulatedError(@class: 0, lineNumber: 0, base.Message, number: 0, procedure: "", server: SimulatedDbConnection.DataSourceName, source: SourceName, state: 0);
5353
this.Errors = new SimulatedErrorCollection([first]);
5454
}
5555
else
@@ -118,6 +118,70 @@ private SimulatedSqlException(string message, params ReadOnlySpan<SimulatedError
118118
/// </summary>
119119
internal bool TerminatesBatch { get; private init; }
120120

121+
/// <summary>
122+
/// Guards <see cref="ResolveDiagnostics"/> against re-stamping. An error
123+
/// born inside a nested body (procedure / dynamic-SQL batch) is resolved at
124+
/// its own dispatch frame's catch boundary; as it propagates outward each
125+
/// enclosing frame must leave the already-resolved line / procedure alone.
126+
/// </summary>
127+
private bool diagnosticsResolved;
128+
129+
/// <summary>
130+
/// Pre-stamps a known line / procedure and marks this exception resolved so
131+
/// the enclosing dispatch frame's <see cref="ResolveDiagnostics"/> leaves
132+
/// it untouched. Used by the <c>THROW;</c> re-raise, which carries the
133+
/// original error's captured line rather than the re-raising statement's.
134+
/// </summary>
135+
internal void PreserveDiagnostics(int line, string? procedure)
136+
{
137+
this.diagnosticsResolved = true;
138+
foreach (var error in this.Errors)
139+
{
140+
error.LineNumber = line;
141+
if (procedure is { Length: > 0 } && error.Procedure.Length == 0)
142+
error.Procedure = procedure;
143+
}
144+
}
145+
146+
/// <summary>
147+
/// Stamps the batch-relative line, server, and enclosing-procedure context
148+
/// onto this exception's <see cref="Errors"/> the first time an enclosing
149+
/// dispatch frame catches it — the ambient-capture point the static error
150+
/// factories can't reach. Runs once (subsequent enclosing frames no-op via
151+
/// <see cref="diagnosticsResolved"/>), so the innermost frame — where the
152+
/// error was actually born — wins, matching SQL Server's innermost-frame
153+
/// attribution for nested procedure calls (probe-confirmed).
154+
/// </summary>
155+
/// <param name="baseLine">
156+
/// The line to attribute when an error carries none of its own: the failing
157+
/// statement's start line for runtime / bind errors, or the parser's
158+
/// current-token line for syntax errors (severity 15). An error that
159+
/// already carries a line (a re-raised <c>THROW;</c> preserving the
160+
/// original) keeps it.
161+
/// </param>
162+
/// <param name="lineOffset">
163+
/// Newline count preceding a procedure body's start within its
164+
/// <c>CREATE</c> text, added so a body error reports the line relative to
165+
/// the whole definition (probe-confirmed). Zero for top-level and
166+
/// dynamic-SQL batches.
167+
/// </param>
168+
/// <param name="procedure">
169+
/// Schema-qualified name of the enclosing procedure body, or empty for
170+
/// top-level / dynamic-SQL batches.
171+
/// </param>
172+
internal void ResolveDiagnostics(int baseLine, int lineOffset, string procedure)
173+
{
174+
if (this.diagnosticsResolved)
175+
return;
176+
this.diagnosticsResolved = true;
177+
foreach (var error in this.Errors)
178+
{
179+
error.LineNumber = (error.LineNumber == 0 ? baseLine : error.LineNumber) + lineOffset;
180+
if (procedure.Length != 0 && error.Procedure.Length == 0)
181+
error.Procedure = procedure;
182+
}
183+
}
184+
121185
/// <summary>
122186
/// Aggregates the errors gathered while draining a batch to completion
123187
/// into a single exception, mirroring how real SqlClient surfaces every

SqlServerSimulator/Network/TdsSession.Cursors.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ private void OpenAndAnnounce(
213213
{
214214
_ = connection.Cursors.Remove(name);
215215
foreach (var error in ex.Errors)
216-
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, error.Server, error.Procedure, error.LineNumber);
216+
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, TdsSession.ServerName, error.Procedure, error.LineNumber);
217217
writer.WriteErrorOrInfo(Tds.TokenError, 16945, 2, 16, "The cursor was not declared.", "SIMULATED", "", 1);
218218

219219
// Echo the requested option values; the handle comes back zero.
@@ -373,7 +373,7 @@ private void CursorOp(TdsRpcRequest request, TdsTokenWriter writer, bool moreReq
373373
catch (SimulatedSqlException ex)
374374
{
375375
foreach (var error in ex.Errors)
376-
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, error.Server, error.Procedure, error.LineNumber);
376+
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, TdsSession.ServerName, error.Procedure, error.LineNumber);
377377
writer.WriteReturnStatus(ex.Errors[0].Number);
378378
this.CompleteCursorRpc(writer, moreRequests, error: true);
379379
return;

SqlServerSimulator/Network/TdsSession.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,13 +393,23 @@ private void ResetConnection()
393393
this.connection = fresh;
394394
}
395395

396+
/// <summary>
397+
/// The server-name field carried by every ERROR / INFO token — the
398+
/// server's own name (<c>@@SERVERNAME</c> / <c>SERVERPROPERTY('ServerName')</c>),
399+
/// which a real SQL Server writes into these tokens and which token-rendering
400+
/// clients (sqlcmd) display. Distinct from <see cref="SimulatedError.Server"/>
401+
/// (the connection data source that SqlClient surfaces on
402+
/// <c>SqlException.Server</c>); SqlClient ignores this token field.
403+
/// </summary>
404+
internal const string ServerName = "SIMULATED";
405+
396406
/// <summary>Writes all queued info messages as INFO tokens; true when any were written.</summary>
397407
private bool FlushInfoMessages(TdsTokenWriter writer)
398408
{
399409
var any = false;
400410
while (this.pendingInfoMessages.TryDequeue(out var error))
401411
{
402-
writer.WriteErrorOrInfo(Tds.TokenInfo, error.Number, error.State, error.Class, error.Message, error.Server, error.Procedure, error.LineNumber);
412+
writer.WriteErrorOrInfo(Tds.TokenInfo, error.Number, error.State, error.Class, error.Message, ServerName, error.Procedure, error.LineNumber);
403413
any = true;
404414
}
405415

@@ -409,7 +419,7 @@ private bool FlushInfoMessages(TdsTokenWriter writer)
409419
private static void WriteErrors(TdsTokenWriter writer, SimulatedSqlException exception)
410420
{
411421
foreach (var error in exception.Errors)
412-
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, error.Server, error.Procedure, error.LineNumber);
422+
writer.WriteErrorOrInfo(Tds.TokenError, error.Number, error.State, error.Class, error.Message, ServerName, error.Procedure, error.LineNumber);
413423
}
414424

415425
/// <summary>Skips the ALL_HEADERS section and decodes the UCS-2 batch text.</summary>

0 commit comments

Comments
 (0)