Skip to content

Commit fde4588

Browse files
committed
Add WAITFOR DELAY '<time>' / WAITFOR DELAY @variable backed by Thread.Sleep(TimeSpan); strict operand grammar (literal or @var only, time-typed var raises Msg 9815, cast/integer/bare NULL fail at parse), Msg 148 verbatim for bad time strings, empty string and NULL-valued variable silently no-op; WAITFOR TIME is NotSupportedException; cancellation-token gap documented.
1 parent e1e2b29 commit fde4588

6 files changed

Lines changed: 360 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ Probe-confirmed semantics (2026-05-11) the simulator handles correctly because e
383383
- Real SQL Server's PRINT truncates messages at 8000 chars (varchar) / 4000 chars (nvarchar). Simulator: no truncation modeled (output is discarded).
384384
- 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.
385385

386+
### `WAITFOR DELAY`
387+
`WAITFOR DELAY '<time>'` and `WAITFOR DELAY @variable` block the calling thread via `Thread.Sleep(TimeSpan)`, matching real SQL Server's "blocks the connection" semantics. Operand grammar is strict (matches probe of SQL Server 2025, 2026-05-11): only a varchar/nvarchar string literal or an `@-variable` reference. `cast(...)`, integer literal, bare `NULL` literal all fail at parse (Msg 102/156); `time`-typed variable raises **Msg 9815** (`"Waitfor delay and waitfor time cannot be of type time."` — note SQL Server reserves the operand slot for *string-typed* values, not its own `time` type). Empty string and NULL-valued variable both silently succeed as zero delay. Bad-format string raises **Msg 148** with the offending value embedded. `@@ROWCOUNT` resets to 0. Skip-mode suppresses the sleep entirely (an `IF 1=0 WAITFOR DELAY '00:00:10'` returns instantly). **`WAITFOR TIME`** (absolute-time wait) raises `NotSupportedException` — scheduling-style primitive not yet needed. **Cancellation gap**: an `ExecuteReaderAsync` caller's `CancellationToken` isn't threaded into the sleep; the thread blocks until the duration elapses regardless.
388+
386389
### Local temp tables (`#foo`)
387390
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.
388391

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
using System.Data.Common;
2+
using System.Diagnostics;
3+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
4+
5+
namespace SqlServerSimulator;
6+
7+
/// <summary>
8+
/// Tests for <c>WAITFOR DELAY</c>. The simulator uses
9+
/// <see cref="Thread.Sleep(TimeSpan)"/> on the calling thread, matching real
10+
/// SQL Server's "blocks the connection" semantics. To keep CI fast, only
11+
/// one test actually sleeps a non-trivial duration; everything else
12+
/// exercises parsing / dispatch / skip-mode / error paths with a
13+
/// <c>'00:00:00'</c> operand. Behavior probed against SQL Server 2025
14+
/// (2026-05-11).
15+
/// </summary>
16+
[TestClass]
17+
public sealed class WaitForDelayTests
18+
{
19+
[TestMethod]
20+
public void Delay_Zero_StringLiteral_ReturnsImmediately()
21+
=> _ = new Simulation().ExecuteNonQuery("waitfor delay '00:00:00'");
22+
23+
[TestMethod]
24+
public void Delay_EmptyString_ReturnsImmediately()
25+
{
26+
// Probe-confirmed: empty string is silently accepted as zero delay.
27+
_ = new Simulation().ExecuteNonQuery("waitfor delay ''");
28+
}
29+
30+
[TestMethod]
31+
public void Delay_Variable_String_ReturnsImmediately()
32+
=> _ = new Simulation().ExecuteNonQuery(
33+
"declare @t varchar(20) = '00:00:00'; waitfor delay @t");
34+
35+
[TestMethod]
36+
public void Delay_Variable_NullValue_ReturnsImmediately()
37+
{
38+
// Probe-confirmed: a NULL-valued variable is silently accepted as
39+
// zero delay (distinct from the bare NULL literal which is a Msg 156
40+
// syntax error at parse time — different code path).
41+
_ = new Simulation().ExecuteNonQuery(
42+
"declare @t varchar(20); waitfor delay @t");
43+
}
44+
45+
/// <summary>
46+
/// The only test that actually sleeps. ~50ms is well under the user's
47+
/// 100ms ceiling; the assertion checks the sleep was at least nearly
48+
/// the requested span (giving 10ms of OS scheduler slack on the lower
49+
/// bound) and not absurdly long.
50+
/// </summary>
51+
[TestMethod]
52+
public void Delay_50ms_ActuallySleeps()
53+
{
54+
var start = Stopwatch.GetTimestamp();
55+
_ = new Simulation().ExecuteNonQuery("waitfor delay '00:00:00.050'");
56+
var elapsed = Stopwatch.GetElapsedTime(start);
57+
IsGreaterThanOrEqualTo(40, elapsed.TotalMilliseconds,
58+
$"Expected ≥40ms sleep, got {elapsed.TotalMilliseconds}ms");
59+
IsLessThan(500, elapsed.TotalMilliseconds,
60+
$"Expected <500ms sleep, got {elapsed.TotalMilliseconds}ms");
61+
}
62+
63+
[TestMethod]
64+
public void Delay_InUntakenIf_DoesNotSleep()
65+
{
66+
// The string operand here would otherwise sleep 10 seconds — verify
67+
// skip-mode suppresses the actual sleep.
68+
var start = Stopwatch.GetTimestamp();
69+
_ = new Simulation().ExecuteNonQuery("if 1=0 waitfor delay '00:00:10'");
70+
var elapsed = Stopwatch.GetElapsedTime(start);
71+
IsLessThan(100, elapsed.TotalMilliseconds,
72+
$"Expected near-instant skip, got {elapsed.TotalMilliseconds}ms");
73+
}
74+
75+
[TestMethod]
76+
public void Delay_AfterReturn_DoesNotSleep()
77+
{
78+
var start = Stopwatch.GetTimestamp();
79+
_ = new Simulation().ExecuteNonQuery("return; waitfor delay '00:00:10'");
80+
var elapsed = Stopwatch.GetElapsedTime(start);
81+
IsLessThan(100, elapsed.TotalMilliseconds,
82+
$"Expected near-instant skip, got {elapsed.TotalMilliseconds}ms");
83+
}
84+
85+
[TestMethod]
86+
public void Delay_AfterBreak_DoesNotSleep()
87+
{
88+
var start = Stopwatch.GetTimestamp();
89+
_ = new Simulation().ExecuteNonQuery("""
90+
while 1=1
91+
begin
92+
break;
93+
waitfor delay '00:00:10';
94+
end
95+
""");
96+
var elapsed = Stopwatch.GetElapsedTime(start);
97+
IsLessThan(100, elapsed.TotalMilliseconds,
98+
$"Expected near-instant skip, got {elapsed.TotalMilliseconds}ms");
99+
}
100+
101+
[TestMethod]
102+
public void Delay_ResetsRowCountToZero()
103+
{
104+
using var reader = new Simulation().ExecuteReader("""
105+
select 1 union all select 2 union all select 3;
106+
waitfor delay '00:00:00';
107+
select @@rowcount as rc
108+
""");
109+
while (reader.Read()) { }
110+
IsTrue(reader.NextResult());
111+
IsTrue(reader.Read());
112+
AreEqual(0, reader.GetInt32(0));
113+
}
114+
115+
[TestMethod]
116+
public void Delay_MalformedString_Msg148()
117+
=> new Simulation().AssertSqlError(
118+
"waitfor delay 'not a time'", 148,
119+
"Incorrect time syntax in time string 'not a time' used with WAITFOR.");
120+
121+
[TestMethod]
122+
public void Delay_Over24h_Msg148()
123+
=> new Simulation().AssertSqlError("waitfor delay '25:00:00'", 148);
124+
125+
[TestMethod]
126+
public void Delay_NegativeString_Msg148()
127+
=> new Simulation().AssertSqlError("waitfor delay '-00:00:01'", 148);
128+
129+
[TestMethod]
130+
public void Delay_DayComponent_Msg148()
131+
=> new Simulation().AssertSqlError("waitfor delay '99:00:00:00'", 148);
132+
133+
[TestMethod]
134+
public void Delay_BadVariableValue_Msg148()
135+
=> new Simulation().AssertSqlError(
136+
"declare @t varchar(20) = 'bogus'; waitfor delay @t", 148,
137+
"Incorrect time syntax in time string 'bogus' used with WAITFOR.");
138+
139+
[TestMethod]
140+
public void Delay_TimeTypedVariable_Msg9815()
141+
=> new Simulation().AssertSqlError(
142+
"declare @t time = '00:00:00.100'; waitfor delay @t", 9815,
143+
"Waitfor delay and waitfor time cannot be of type time.");
144+
145+
[TestMethod]
146+
public void Delay_IntegerLiteralOperand_SyntaxError()
147+
{
148+
// Real SQL Server raises Msg 102 here; the simulator routes through
149+
// the same Msg-102 factory (SyntaxErrorNear).
150+
var ex = Throws<DbException>(
151+
() => _ = new Simulation().ExecuteNonQuery("waitfor delay 1"));
152+
AreEqual("102", ex.Data["HelpLink.EvtID"]);
153+
}
154+
155+
[TestMethod]
156+
public void Delay_BareNullLiteralOperand_SyntaxError()
157+
{
158+
// Real SQL Server: Msg 156 "Incorrect syntax near the keyword 'null'."
159+
// Simulator: Msg 102 (NULL falls through to the operand's
160+
// SyntaxErrorNear catch-all). Wording differs but rejection is
161+
// consistent — a hand-typed `waitfor delay null` is a programmer
162+
// error either way.
163+
var ex = Throws<DbException>(
164+
() => _ = new Simulation().ExecuteNonQuery("waitfor delay null"));
165+
var num = ex.Data["HelpLink.EvtID"] as string;
166+
IsTrue(num is "102" or "156", $"Expected Msg 102 or 156, got {num}");
167+
}
168+
169+
[TestMethod]
170+
public void Delay_CastOperand_SyntaxError()
171+
=> _ = Throws<DbException>(
172+
() => _ = new Simulation().ExecuteNonQuery(
173+
"waitfor delay cast('00:00:00.050' as time)"));
174+
175+
[TestMethod]
176+
public void WaitforTime_NotSupported()
177+
=> _ = Throws<NotSupportedException>(
178+
() => _ = new Simulation().ExecuteNonQuery("waitfor time '23:59:59'"));
179+
180+
[TestMethod]
181+
public void Delay_BetweenSelects_BothRun()
182+
{
183+
using var reader = new Simulation().ExecuteReader(
184+
"select 'before' as v; waitfor delay '00:00:00'; select 'after' as v");
185+
IsTrue(reader.Read());
186+
AreEqual("before", reader.GetString(0));
187+
IsTrue(reader.NextResult());
188+
IsTrue(reader.Read());
189+
AreEqual("after", reader.GetString(0));
190+
}
191+
192+
[TestMethod]
193+
public void Delay_InWhile_RunsEachIteration()
194+
{
195+
// Three iterations, each waiting 0 — exercises dispatch in the loop
196+
// body. The loop completes in nominal time.
197+
var start = Stopwatch.GetTimestamp();
198+
_ = new Simulation().ExecuteNonQuery("""
199+
declare @i int = 0;
200+
while @i < 3
201+
begin
202+
set @i = @i + 1;
203+
waitfor delay '00:00:00';
204+
end
205+
""");
206+
var elapsed = Stopwatch.GetElapsedTime(start);
207+
IsLessThan(500, elapsed.TotalMilliseconds,
208+
$"Expected fast loop, got {elapsed.TotalMilliseconds}ms");
209+
}
210+
}

SqlServerSimulator/Errors/SimulatedSqlException.QueryErrors.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,26 @@ internal static SimulatedSqlException IntegerIndexNotAllowedInOrderedAggregate()
326326
/// </summary>
327327
internal static SimulatedSqlException AllResultsInCaseAreNull() =>
328328
new("At least one of the result expressions in a CASE specification must be an expression other than the NULL constant.", 8133, 16, 1);
329+
330+
/// <summary>
331+
/// Mimics SQL Server's Msg 148 — the string operand of <c>WAITFOR DELAY</c>
332+
/// (or <c>WAITFOR TIME</c>, not modeled) wasn't a valid time format. Probe-
333+
/// confirmed against SQL Server 2025 (2026-05-11): Class 15, State 1,
334+
/// verbatim wording. Valid format is <c>HH:MM:SS[.fff]</c> with hours
335+
/// 0-23 and no leading sign. Negative, day-component, and over-24h
336+
/// strings all surface this same error.
337+
/// </summary>
338+
internal static SimulatedSqlException IncorrectWaitForTimeSyntax(string value) =>
339+
new($"Incorrect time syntax in time string '{value}' used with WAITFOR.", 148, 15, 1);
340+
341+
/// <summary>
342+
/// Mimics SQL Server's Msg 9815 — the operand of <c>WAITFOR DELAY</c>
343+
/// (or <c>WAITFOR TIME</c>) is a variable typed as <c>time</c>. Real
344+
/// SQL Server reserves the operand for char/varchar/nchar/nvarchar
345+
/// values; the <c>time</c> type is paradoxically rejected. Probe-
346+
/// confirmed against SQL Server 2025 (2026-05-11): Class 16, State 0,
347+
/// verbatim wording.
348+
/// </summary>
349+
internal static SimulatedSqlException WaitForCannotBeTimeType() =>
350+
new("Waitfor delay and waitfor time cannot be of type time.", 9815, 16, 0);
329351
}

SqlServerSimulator/Parser/Selection.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +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
426+
or Keyword.Print or Keyword.WaitFor
427427
} when depth == 0:
428428
goto ExitWhileTokenLoop;
429429

@@ -567,7 +567,7 @@ or Keyword.Merge or Keyword.Begin or Keyword.Commit or Keyword.Rollback
567567
or Keyword.Save or Keyword.Create or Keyword.Drop or Keyword.Alter or Keyword.Dbcc
568568
or Keyword.Set or Keyword.Declare or Keyword.If or Keyword.Else or Keyword.End
569569
or Keyword.While or Keyword.Break or Keyword.Continue or Keyword.Return
570-
or Keyword.Print
570+
or Keyword.Print or Keyword.WaitFor
571571
} when depth == 0:
572572
goto ExitWhileTokenLoop;
573573

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System.Globalization;
2+
using SqlServerSimulator.Parser;
3+
using SqlServerSimulator.Parser.Tokens;
4+
using SqlServerSimulator.Storage;
5+
6+
namespace SqlServerSimulator;
7+
8+
partial class Simulation
9+
{
10+
/// <summary>
11+
/// Parses and runs <c>WAITFOR DELAY '&lt;time&gt;'</c> or
12+
/// <c>WAITFOR DELAY @variable</c>. Probed against SQL Server 2025
13+
/// (2026-05-11): the operand grammar is strict — only a string literal
14+
/// or a <c>@variable</c> reference; <c>cast(...)</c>, integer literals,
15+
/// the bare <c>NULL</c> literal, and a <c>time</c>-typed variable are
16+
/// all rejected by real SQL Server (Msg 102 / Msg 156 / Msg 9815),
17+
/// and the simulator inherits that rejection by not parsing those
18+
/// shapes. <c>WAITFOR TIME</c> (the absolute-time form) raises
19+
/// <see cref="NotSupportedException"/>.
20+
/// </summary>
21+
/// <remarks>
22+
/// <para>
23+
/// Time format: <c>HH:MM:SS[.fff]</c> (or <c>HH:MM</c>) with hours 0-23
24+
/// and no sign or day component. Bad format → Msg 148. An empty string
25+
/// or a NULL-valued variable is silently accepted as a zero delay
26+
/// (probe-confirmed: <c>waitfor delay ''</c> and a NULL-valued varchar
27+
/// both succeed without sleeping).
28+
/// </para>
29+
/// <para>
30+
/// Sleep mechanism: <see cref="Thread.Sleep(TimeSpan)"/> on the calling
31+
/// thread, matching real SQL Server's "blocks the connection" semantics.
32+
/// The simulator's dispatch is synchronous; an <c>ExecuteReaderAsync</c>
33+
/// caller's <c>CancellationToken</c> isn't threaded into the sleep —
34+
/// documented gap in CLAUDE.md. <c>@@ROWCOUNT</c> resets to 0
35+
/// (probe-confirmed; applied by the dispatcher after this parser returns).
36+
/// Skip-mode (un-taken IF, after BREAK/CONTINUE/RETURN) suppresses the
37+
/// sleep entirely.
38+
/// </para>
39+
/// </remarks>
40+
private static void ParseWaitForStatement(BatchContext batch)
41+
{
42+
var context = batch.Parser;
43+
context.MoveNextRequired(); // consume WAITFOR
44+
45+
// DELAY and TIME are contextual keywords (not in the reserved list),
46+
// tokenized as UnquotedString. WAITFOR TIME isn't modeled — it's an
47+
// absolute-time wait whose primary use case is scheduling, which is
48+
// out of scope for the simulator.
49+
if (context.Token is not UnquotedString verb)
50+
throw SimulatedSqlException.SyntaxErrorNear(context);
51+
if (verb.Span.Equals("TIME", StringComparison.OrdinalIgnoreCase))
52+
throw new NotSupportedException("WAITFOR TIME (absolute-time wait) isn't modeled — WAITFOR DELAY is.");
53+
if (!verb.Span.Equals("DELAY", StringComparison.OrdinalIgnoreCase))
54+
throw SimulatedSqlException.SyntaxErrorNear(context);
55+
56+
context.MoveNextRequired(); // consume DELAY
57+
58+
// Capture the raw string operand. The grammar is strict: literal or
59+
// @-prefixed variable reference only. Anything else (cast, integer
60+
// literal, bare NULL, paren-expr) falls through to Msg 102 / Msg 156
61+
// from real SQL Server; the simulator routes them all through the
62+
// existing SyntaxErrorNear path which produces Msg 102.
63+
string? operandText;
64+
switch (context.Token)
65+
{
66+
case Literal lit when lit.Value.Type is VarcharSqlType or NVarcharSqlType:
67+
operandText = lit.Value.IsNull ? null : lit.Value.AsString;
68+
break;
69+
70+
case AtPrefixedString variableToken:
71+
{
72+
var slot = batch.GetVariableSlot(variableToken.Value);
73+
if (slot.DeclaredType is TimeSqlType)
74+
throw SimulatedSqlException.WaitForCannotBeTimeType();
75+
operandText = slot.Value.IsNull ? null : slot.Value.CoerceTo(SqlType.Varchar).AsString;
76+
break;
77+
}
78+
79+
default:
80+
throw SimulatedSqlException.SyntaxErrorNear(context);
81+
}
82+
83+
context.MoveNextOptional(); // consume the operand token
84+
85+
if (batch.IsSkipping)
86+
return;
87+
88+
// Probe-confirmed: NULL via variable, and empty string, both succeed
89+
// silently with zero delay.
90+
if (string.IsNullOrEmpty(operandText))
91+
return;
92+
93+
if (!TryParseWaitForTime(operandText, out var delay))
94+
throw SimulatedSqlException.IncorrectWaitForTimeSyntax(operandText);
95+
96+
if (delay.Ticks > 0)
97+
Thread.Sleep(delay);
98+
}
99+
100+
private static readonly string[] waitForTimeFormats =
101+
[
102+
@"hh\:mm\:ss",
103+
@"hh\:mm\:ss\.f",
104+
@"hh\:mm\:ss\.ff",
105+
@"hh\:mm\:ss\.fff",
106+
@"hh\:mm\:ss\.ffff",
107+
@"hh\:mm\:ss\.fffff",
108+
@"hh\:mm\:ss\.ffffff",
109+
@"hh\:mm\:ss\.fffffff",
110+
@"hh\:mm",
111+
];
112+
113+
private static bool TryParseWaitForTime(string value, out TimeSpan result) =>
114+
TimeSpan.TryParseExact(value, waitForTimeFormats, CultureInfo.InvariantCulture, out result)
115+
&& result.Ticks is >= 0 and < TimeSpan.TicksPerDay;
116+
}

0 commit comments

Comments
 (0)