Skip to content

Commit 9fab873

Browse files
committed
Binary-operator parsing becomes iterative precedence-climbing, AND/OR collapse to n-ary nodes and TwoSidedExpression evaluates its left spine in a loop so flat operator chains are uncapped and can no longer stack-overflow the process at Run time, CASE/IIF nesting caps at 10, and parens/functions/subqueries share a weighted nesting budget at caps that stay deterministic on 1 MB Debug stacks.
1 parent 3e040fa commit 9fab873

20 files changed

Lines changed: 779 additions & 334 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ obj/
1212

1313
# Suppress temporary files that might otherwise leak into a commit.
1414
tmp/
15+
.claude/worktrees/

SqlServerSimulator.Tests/ExpressionNestingLimitTests.cs

Lines changed: 221 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,51 +3,252 @@
33
namespace SqlServerSimulator;
44

55
/// <summary>
6-
/// Expression-nesting limits. Expression parsing recurses per operator and
7-
/// grouping level, and a .NET stack overflow is uncatchable and
8-
/// process-fatal — so pathological depth must surface as SQL Server's own
9-
/// graceful errors instead. Probed against SQL Server 2025 (2026-07-15):
10-
/// a 6000-term <c>1 + 1 + …</c> chain raises Msg 8631 Class 17 ("Server
11-
/// stack limit"), a stack-dependent threshold the simulator mirrors via the
12-
/// runtime's remaining-stack probe (so its threshold likewise scales with
13-
/// the calling thread's stack size, firing near 750 levels on a default
14-
/// 1 MB thread); 2000 nested parens raise Msg 191 Class 15, a structural
15-
/// limit the simulator enforces at 512 so the paren shape reports Msg 191
16-
/// before the stack probe would claim it.
6+
/// Expression depth limits. Two failure modes must never reach the host: a
7+
/// .NET stack overflow (uncatchable, process-fatal) from deep recursion, and
8+
/// a silently-wrong result. So flat left-associative chains parse and evaluate
9+
/// iteratively (no per-term recursion, no artificial cap — matching real SQL
10+
/// Server's tolerance of thousands of terms), while genuinely-nested shapes are
11+
/// bounded by SQL Server's own graceful structural errors:
12+
/// <list type="bullet">
13+
/// <item>Msg 191 (Class 15) — parens / subquery / function-argument nesting,
14+
/// via the shared weighted budget (probe-confirmed 2026-07-18: real pools
15+
/// these into one budget where a subquery level costs ≈ 6 paren levels). The
16+
/// simulator's parse frames are fatter than real's, so the caps are lower than
17+
/// real's stack-dependent thresholds (500 parens vs 1015, 83 subqueries vs
18+
/// 168) but keep the probed ratio — a documented divergence.</item>
19+
/// <item>Msg 125 (Class 15) — CASE / IIF nested past ten levels; State 4 for
20+
/// CASE, State 2 for IIF (the construct entered at the eleventh level).</item>
21+
/// <item>Msg 8631 (Class 17) — the runtime stack probe, the backstop for any
22+
/// deep recursive shape whose frames outrun a deterministic cap (deep function
23+
/// nesting reaches it near ~80 levels on a 1 MB thread; real raises Msg 191 at
24+
/// 1013 — a documented divergence).</item>
25+
/// </list>
1726
/// </summary>
1827
[TestClass]
1928
public sealed class ExpressionNestingLimitTests
2029
{
2130
private static string AdditionChain(int terms) =>
2231
$"select ({string.Join(" + ", Enumerable.Repeat("1", terms))})";
2332

33+
// --- Flat left-associative chains: no cap, iterative parse + evaluation. ---
34+
2435
[TestMethod]
2536
public void AdditionChain_ModerateDepth_Evaluates()
37+
=> AreEqual(200, new Simulation().ExecuteScalar(AdditionChain(200)));
38+
39+
[TestMethod]
40+
[Timeout(60000)]
41+
public void AdditionChain_ExtremeDepth_Evaluates()
42+
=> AreEqual(50000, new Simulation().ExecuteScalar(AdditionChain(50000)));
43+
44+
[TestMethod]
45+
[Timeout(60000)]
46+
public void ConcatChain_ExtremeDepth_Evaluates()
47+
=> AreEqual(new string('a', 20000), new Simulation().ExecuteScalar(
48+
"select " + string.Join(" + ", Enumerable.Repeat("'a'", 20000))));
49+
50+
[TestMethod]
51+
[Timeout(60000)]
52+
public void AndChain_ExtremeDepth_Evaluates()
53+
=> AreEqual(1, new Simulation().ExecuteScalar(
54+
"select 1 where " + string.Join(" and ", Enumerable.Repeat("1=1", 50000))));
55+
56+
[TestMethod]
57+
[Timeout(60000)]
58+
public void OrChain_ExtremeDepth_Evaluates()
59+
=> AreEqual(1, new Simulation().ExecuteScalar(
60+
"select 1 where " + string.Join(" or ", Enumerable.Repeat("1=0", 49999)) + " or 1=1"));
61+
62+
// --- Hardening: a small-stack thread must never process-death. ---
63+
64+
[TestMethod]
65+
[Timeout(60000)]
66+
public void AndChain_ExtremeDepth_OnSmallStackThread_IsGracefulNeverProcessDeath()
2667
{
27-
AreEqual(200, new Simulation().ExecuteScalar(AdditionChain(200)));
68+
// A 50,000-term flat AND chain used to overflow the host at Run time
69+
// (the boolean spine recursed once per term). Evaluation is now
70+
// iterative; running it on a deliberately small (512 KB) thread proves
71+
// the whole parse + run path is recursion-bounded. A stack overflow
72+
// would fault the process rather than fail the test, so reaching the
73+
// assertion at all is the guarantee.
74+
var sql = "select 1 where " + string.Join(" and ", Enumerable.Repeat("1=1", 50000));
75+
RunOnSmallStack(sql, out var result, out var failure);
76+
IsNull(failure, "deep AND chain should evaluate, not raise");
77+
AreEqual(1, result);
2878
}
2979

3080
[TestMethod]
3181
[Timeout(60000)]
32-
public void AdditionChain_ExtremeDepth_RaisesMsg8631NotStackOverflow()
82+
public void NotChain_ExtremeDepth_OnSmallStackThread_IsGracefulNeverProcessDeath()
83+
{
84+
// `NOT NOT … p` used to recurse per NOT at Run time. NOT-runs now
85+
// collapse at parse (three-valued NOT is an involution), so an
86+
// even-length stack is the identity — here 50,000 NOTs over 1=1.
87+
var sql = "select 1 where " + string.Concat(Enumerable.Repeat("not ", 50000)) + "1=1";
88+
RunOnSmallStack(sql, out var result, out var failure);
89+
IsNull(failure, "deep NOT stack should evaluate, not raise");
90+
AreEqual(1, result);
91+
}
92+
93+
private void RunOnSmallStack(string sql, out object? result, out Exception? failure)
3394
{
34-
var ex = new Simulation().AssertSqlError(AdditionChain(50000), 8631);
35-
AreEqual(17, ex.Class);
36-
AreEqual("Internal error: Server stack limit has been reached. Please look for potentially deep nesting in your query, and try to simplify it.", ex.Message);
95+
object? captured = null;
96+
Exception? caught = null;
97+
var thread = new Thread(() =>
98+
{
99+
try
100+
{
101+
captured = new Simulation().ExecuteScalar(sql);
102+
}
103+
catch (Exception ex) when (ex is SimulatedSqlException or NotSupportedException)
104+
{
105+
caught = ex;
106+
}
107+
}, maxStackSize: 512 * 1024);
108+
thread.Start();
109+
IsTrue(thread.Join(TimeSpan.FromSeconds(30)), "query did not complete on the small-stack thread");
110+
result = captured;
111+
failure = caught;
37112
}
38113

114+
// --- Nested parens: shared budget, Msg 191. ---
115+
39116
[TestMethod]
40117
public void NestedParens_WithinLimit_Evaluates()
118+
=> AreEqual(1, new Simulation().ExecuteScalar($"select {new string('(', 200)}1{new string(')', 200)}"));
119+
120+
[TestMethod]
121+
public void NestedParens_AtLimit_Evaluates()
122+
=> AreEqual(1, new Simulation().ExecuteScalar($"select {new string('(', 500)}1{new string(')', 500)}"));
123+
124+
[TestMethod]
125+
public void NestedParens_OverLimit_RaisesMsg191()
126+
{
127+
var ex = new Simulation().AssertSqlError($"select {new string('(', 501)}1{new string(')', 501)}", 191);
128+
AreEqual(15, ex.Class);
129+
AreEqual("Some part of your SQL statement is nested too deeply. Rewrite the query or break it up into smaller queries.", ex.Message);
130+
}
131+
132+
// --- Nested scalar subqueries: shared budget (≈ 6× a paren), Msg 191. ---
133+
134+
private static string NestedSubqueries(int depth)
41135
{
42-
AreEqual(1, new Simulation().ExecuteScalar($"select {new string('(', 200)}1{new string(')', 200)}"));
136+
var q = "select 1";
137+
for (var i = 0; i < depth; i++)
138+
q = $"select ({q})";
139+
return q;
43140
}
44141

142+
[TestMethod]
143+
public void NestedSubqueries_AtLimit_Evaluates()
144+
=> AreEqual(1, new Simulation().ExecuteScalar(NestedSubqueries(83)));
145+
146+
[TestMethod]
147+
public void NestedSubqueries_OverLimit_RaisesMsg191()
148+
=> AreEqual(15, new Simulation().AssertSqlError(NestedSubqueries(84), 191).Class);
149+
150+
// --- Nested functions: charged to the shared budget (Msg 191 at the cap
151+
// on an adequate stack) but with fatter frames, so on a tight stack the
152+
// runtime probe (Msg 8631) pre-empts — both graceful, never process death. ---
153+
45154
[TestMethod]
46155
[Timeout(60000)]
47-
public void NestedParens_OverLimit_RaisesMsg191()
156+
public void NestedFunctions_OverBudget_RaisesMsg191()
48157
{
49-
var ex = new Simulation().AssertSqlError($"select {new string('(', 513)}1{new string(')', 513)}", 191);
158+
// 501 nested ABS = 501 budget units > 500. On a normal (large) test
159+
// thread the structural cap trips before the stack probe.
160+
var sql = "select " + string.Concat(Enumerable.Repeat("abs(", 501)) + "1" + new string(')', 501);
161+
AreEqual(15, new Simulation().AssertSqlError(sql, 191).Class);
162+
}
163+
164+
[TestMethod]
165+
[Timeout(60000)]
166+
public void NestedFunctions_ExtremeDepth_OnSmallStackThread_RaisesMsg8631NotProcessDeath()
167+
{
168+
// On a 512 KB thread the fat function frames exhaust the stack around
169+
// ~40 levels — far below the 500-unit budget — so the runtime probe
170+
// converts the overflow into Msg 8631 rather than faulting the process.
171+
var sql = "select " + string.Concat(Enumerable.Repeat("abs(", 5000)) + "1" + new string(')', 5000);
172+
RunOnSmallStack(sql, out _, out var failure);
173+
var ex = IsInstanceOfType<SimulatedSqlException>(failure);
174+
AreEqual(8631, ex.Number);
175+
}
176+
177+
// --- Nested CASE / IIF: lexical cap of ten, Msg 125. ---
178+
179+
private static string NestedCase(int depth, string innermost = "1")
180+
{
181+
var q = innermost;
182+
for (var i = 0; i < depth; i++)
183+
q = $"case when 1=1 then {q} else 0 end";
184+
return q;
185+
}
186+
187+
private static string NestedIif(int depth, string innermost = "1")
188+
{
189+
var q = innermost;
190+
for (var i = 0; i < depth; i++)
191+
q = $"iif(1=1, {q}, 0)";
192+
return q;
193+
}
194+
195+
[TestMethod]
196+
public void NestedCase_AtLimit_Evaluates()
197+
=> AreEqual(1, new Simulation().ExecuteScalar($"select {NestedCase(10)}"));
198+
199+
[TestMethod]
200+
public void NestedCase_OverLimit_RaisesMsg125_State4()
201+
{
202+
var ex = new Simulation().AssertSqlError($"select {NestedCase(11)}", 125);
50203
AreEqual(15, ex.Class);
51-
AreEqual("Some part of your SQL statement is nested too deeply. Rewrite the query or break it up into smaller queries.", ex.Message);
204+
AreEqual(4, ex.State);
205+
AreEqual("Case expressions may only be nested to level 10.", ex.Message);
206+
}
207+
208+
[TestMethod]
209+
public void NestedIif_OverLimit_RaisesMsg125_State2()
210+
{
211+
var ex = new Simulation().AssertSqlError($"select {NestedIif(11)}", 125);
212+
AreEqual(15, ex.Class);
213+
AreEqual(2, ex.State);
214+
}
215+
216+
[TestMethod]
217+
public void NestedCase_InWhenCondition_CountsTowardLimit()
218+
{
219+
// Nesting in a WHEN condition counts identically to a THEN/ELSE result
220+
// (probe-confirmed). Build eleven CASEs each nested in the next WHEN.
221+
var predicate = "1=1";
222+
for (var i = 0; i < 11; i++)
223+
predicate = $"case when {predicate} then 1 else 0 end = 1";
224+
AreEqual(4, new Simulation().AssertSqlError($"select 1 where {predicate}", 125).State);
225+
}
226+
227+
[TestMethod]
228+
public void NestedCase_AcrossSubqueryBoundary_DoesNotReset()
229+
{
230+
// The CASE-depth counter is not reset by a scalar-subquery boundary
231+
// (probe-confirmed): eight outer CASE levels + a subquery + five inner
232+
// = thirteen lexical levels, tripping Msg 125.
233+
var sql = $"select {NestedCase(8, $"(select {NestedCase(5)})")}";
234+
AreEqual(4, new Simulation().AssertSqlError(sql, 125).State);
235+
}
236+
237+
[TestMethod]
238+
public void NestedCase_MixedWithIif_SharesCounter_StateFromInnermost()
239+
{
240+
// Ten IIF levels inside one CASE — the eleventh (innermost-entered)
241+
// construct is an IIF, so State is 2 despite the outermost being CASE.
242+
AreEqual(2, new Simulation().AssertSqlError($"select {NestedCase(1, NestedIif(10))}", 125).State);
243+
}
244+
245+
// --- Shared budget: parens and subqueries draw from one pool. ---
246+
247+
[TestMethod]
248+
public void SharedBudget_ParensPlusSubquery_RaisesMsg191()
249+
{
250+
// 480 parens (480 units) wrapping a 4-deep subquery (24 units) = 504 > 500.
251+
var sql = $"select {new string('(', 480)}{NestedSubqueries(4)}{new string(')', 480)}";
252+
_ = new Simulation().AssertSqlError(sql, 191);
52253
}
53254
}

SqlServerSimulator/Errors/SimulatedSqlException.SyntaxErrors.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ internal static SimulatedSqlException ServerStackLimitReached() =>
4040
internal static SimulatedSqlException StatementNestedTooDeeply() =>
4141
new("Some part of your SQL statement is nested too deeply. Rewrite the query or break it up into smaller queries.", 191, 15, 1);
4242

43+
/// <summary>
44+
/// Mimics SQL Server error 125: <c>CASE</c> / <c>IIF</c> expressions were
45+
/// lexically nested beyond ten levels. Probe-confirmed 2026-07-18: Class
46+
/// 15, exact wording verbatim, and the <b>state</b> identifies the
47+
/// construct being entered when the eleventh level is reached — <c>4</c>
48+
/// for a searched/simple <c>CASE</c>, <c>2</c> for <c>IIF</c> (which
49+
/// desugars to a searched CASE). Nesting in a <c>WHEN</c> condition
50+
/// counts identically to a <c>THEN</c> / <c>ELSE</c> result, and the
51+
/// count is not reset by an intervening scalar-subquery boundary.
52+
/// </summary>
53+
internal static SimulatedSqlException CaseExpressionsNestedTooDeeply(byte state) =>
54+
new("Case expressions may only be nested to level 10.", 125, 15, state);
55+
4356
internal static SimulatedSqlException SyntaxErrorNearKeyword(ReservedKeyword token) => new($"Incorrect syntax near the keyword '{token}'.", 156, 15, 1);
4457

4558
/// <summary>

0 commit comments

Comments
 (0)