Skip to content

Commit 4d978b4

Browse files
Descend into UDF and procedure bodies inside cursor plans
#456 taught the parser to read StoredProc/UDF sub-plan bodies, but that descent lives in ParseStatement - and a StmtCursor's operation statements never pass through ParseStatement. They are built in the cursor branch of ParseStatementAndChildren straight from CursorPlan > Operation > QueryPlan, so a function called by the cursor's query carried its whole body in the XML (the Operation element holds the UDF sub-plan beside its QueryPlan) and the parser dropped every statement of it: not enumerated, not analyzed, not counted. Same failure mode as #455 - well-formed, plausible, and silently incomplete output. Fix (#491): extract ParseStatement's UDF/StoredProc reads into a shared ParseSubPlans helper and call it from the cursor branch on each Operation element, attaching the bodies to that operation's statement. That is the whole integration: PlanStatements.EnumerateAll already walks UdfPlans/StoredProcPlan on every statement it yields, and since #486 every consumer (analyzer, scorer, result mapper, statements grid, web viewer) reads that traversal, so the bodies flow through analysis and counts with no consumer changes. The caller's depth carries into the new descent unchanged, preserving #484: a generated bomb alternating cursor and procedure shapes past MaxParseDepth throws the catchable depth error on the big-stack thread, and was verified to fail cleanly (parse completes, assert reports the miss) against a simulated depth reset at the cursor boundary. Tests use generated XML on the depth-limit tests' precedent - a cursor wrapping a UDF sub-plan is three nested element shapes, stated more clearly by a minimal document than a captured fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvAv72Pwb8czsjDWsCCk7n
1 parent 05cc205 commit 4d978b4

3 files changed

Lines changed: 317 additions & 40 deletions

File tree

‎src/PlanViewer.Core/Services/ShowPlanParser.cs‎

Lines changed: 73 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,20 @@ private static List<PlanStatement> ParseStatementAndChildren(
214214
stmt.CursorRequestedType = cursorRequestedType;
215215
stmt.CursorConcurrency = cursorConcurrency;
216216
stmt.CursorForwardOnly = cursorForwardOnly;
217+
218+
/* #491: the same StoredProc/UDF descent every other statement shape gets.
219+
#456 taught ParseStatement to read sub-plan bodies, but a cursor's
220+
operation statements are built HERE, through ParseQueryPlanAsStatement,
221+
and never pass through ParseStatement - so a function called by the
222+
cursor's query carried its whole body in the XML (the Operation element
223+
holds the UDF sub-plan right next to this QueryPlan) and the parser
224+
dropped every statement of it. Attaching the bodies to the operation's
225+
statement is all it takes: PlanStatements.EnumerateAll already walks
226+
UdfPlans/StoredProcPlan on every statement it yields, and since #486
227+
every consumer reads that traversal. Depth passes through unchanged
228+
(#484) - resetting it at this boundary would reopen the MaxParseDepth
229+
bypass across cursor/procedure nesting. */
230+
ParseSubPlans(stmt, opEl, depth, cancellationToken);
217231
results.Add(stmt);
218232
}
219233
}
@@ -300,46 +314,7 @@ prevent. Carrying the caller's depth through this method closes that reset. */
300314
so it took that early return and never reached this code, seventy lines further down. The
301315
parser looked like it descended into procedures and in the one case that matters never
302316
did. The same was true of a UDF call whose statement carries no plan of its own. */
303-
// XSD gap: UDF sub-plans
304-
foreach (var udfEl in stmtEl.Elements(Ns + "UDF"))
305-
{
306-
var udfInfo = new FunctionPlanInfo
307-
{
308-
ProcName = udfEl.Attribute("ProcName")?.Value ?? "",
309-
IsNativelyCompiled = udfEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
310-
};
311-
var udfStmts = udfEl.Element(Ns + "Statements");
312-
if (udfStmts != null)
313-
{
314-
foreach (var childStmt in udfStmts.Elements())
315-
{
316-
var parsed = ParseStatementAndChildren(childStmt, depth + 1, cancellationToken);
317-
udfInfo.Statements.AddRange(parsed);
318-
}
319-
}
320-
stmt.UdfPlans.Add(udfInfo);
321-
}
322-
323-
// XSD gap: StoredProc sub-plan
324-
var storedProcEl = stmtEl.Element(Ns + "StoredProc");
325-
if (storedProcEl != null)
326-
{
327-
var spInfo = new FunctionPlanInfo
328-
{
329-
ProcName = storedProcEl.Attribute("ProcName")?.Value ?? "",
330-
IsNativelyCompiled = storedProcEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
331-
};
332-
var spStmts = storedProcEl.Element(Ns + "Statements");
333-
if (spStmts != null)
334-
{
335-
foreach (var childStmt in spStmts.Elements())
336-
{
337-
var parsed = ParseStatementAndChildren(childStmt, depth + 1, cancellationToken);
338-
spInfo.Statements.AddRange(parsed);
339-
}
340-
}
341-
stmt.StoredProcPlan = spInfo;
342-
}
317+
ParseSubPlans(stmt, stmtEl, depth, cancellationToken);
343318

344319
if (queryPlanEl == null)
345320
{
@@ -400,6 +375,64 @@ did. The same was true of a UDF call whose statement carries no plan of its own.
400375
return stmt;
401376
}
402377

378+
/// <summary>
379+
/// Reads the StoredProc/UDF sub-plan bodies hanging off <paramref name="containerEl"/> onto
380+
/// <paramref name="stmt"/>. One reader shared by ParseStatement — where StmtSimple carries the
381+
/// UDF/StoredProc elements directly — and the StmtCursor branch (#491), where the same UDF
382+
/// element sits beside the QueryPlan under CursorPlan &gt; Operation instead, so the two shapes
383+
/// cannot drift apart the way the analyzer and the mapper once did (#455).
384+
/// The caller's depth carries into the body statements (#484): this descent is what makes
385+
/// module nesting recursive, and resetting depth at a sub-plan boundary is exactly the
386+
/// MaxParseDepth bypass #484 closed.
387+
/// </summary>
388+
private static void ParseSubPlans(
389+
PlanStatement stmt,
390+
XElement containerEl,
391+
int depth,
392+
CancellationToken cancellationToken)
393+
{
394+
// XSD gap: UDF sub-plans
395+
foreach (var udfEl in containerEl.Elements(Ns + "UDF"))
396+
{
397+
var udfInfo = new FunctionPlanInfo
398+
{
399+
ProcName = udfEl.Attribute("ProcName")?.Value ?? "",
400+
IsNativelyCompiled = udfEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
401+
};
402+
var udfStmts = udfEl.Element(Ns + "Statements");
403+
if (udfStmts != null)
404+
{
405+
foreach (var childStmt in udfStmts.Elements())
406+
{
407+
var parsed = ParseStatementAndChildren(childStmt, depth + 1, cancellationToken);
408+
udfInfo.Statements.AddRange(parsed);
409+
}
410+
}
411+
stmt.UdfPlans.Add(udfInfo);
412+
}
413+
414+
// XSD gap: StoredProc sub-plan
415+
var storedProcEl = containerEl.Element(Ns + "StoredProc");
416+
if (storedProcEl != null)
417+
{
418+
var spInfo = new FunctionPlanInfo
419+
{
420+
ProcName = storedProcEl.Attribute("ProcName")?.Value ?? "",
421+
IsNativelyCompiled = storedProcEl.Attribute("IsNativelyCompiled")?.Value is "true" or "1"
422+
};
423+
var spStmts = storedProcEl.Element(Ns + "Statements");
424+
if (spStmts != null)
425+
{
426+
foreach (var childStmt in spStmts.Elements())
427+
{
428+
var parsed = ParseStatementAndChildren(childStmt, depth + 1, cancellationToken);
429+
spInfo.Statements.AddRange(parsed);
430+
}
431+
}
432+
stmt.StoredProcPlan = spInfo;
433+
}
434+
}
435+
403436
/// <summary>
404437
/// Parse a QueryPlan element that comes from a cursor Operation (no parent StmtSimple attributes).
405438
/// </summary>
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
using System.Linq;
2+
using PlanViewer.Core.Output;
3+
using PlanViewer.Core.Services;
4+
5+
namespace PlanViewer.Core.Tests;
6+
7+
/// <summary>
8+
/// #491: #456 taught the parser to descend into StoredProc/UDF sub-plans, but that descent lives
9+
/// in ParseStatement — and a StmtCursor's operation statements never go through ParseStatement.
10+
/// They are built in ParseStatementAndChildren's cursor branch, straight from
11+
/// CursorPlan &gt; Operation &gt; QueryPlan, so a function called by the cursor's query carried
12+
/// its whole body in the XML (the Operation element holds the UDF sub-plan beside its QueryPlan)
13+
/// and the parser dropped every statement of it. Same failure mode as #455: the output stayed
14+
/// well-formed and plausible — the cursor's own query analyzed, the function body silently
15+
/// contributed nothing.
16+
///
17+
/// <para>The plan XML here is generated rather than captured, on the depth-limit tests'
18+
/// precedent: a cursor wrapping a UDF sub-plan is three nested element shapes, and a minimal
19+
/// document states that more clearly than a 40KB fixture could. The element shapes mirror the
20+
/// real showplan schema — StmtCursor &gt; CursorPlan &gt; Operation &gt; QueryPlan, with the UDF
21+
/// element and its Statements sitting beside the QueryPlan under Operation — matching the
22+
/// namespace and attributes the parser reads from committed fixtures.</para>
23+
/// </summary>
24+
public sealed class CursorSubPlanTests
25+
{
26+
/* One cursor operation whose query calls dbo.CursorFn; the function body carries two
27+
statements — one bare (RETURN, no QueryPlan of its own, like the EXEC in #455) and one
28+
with a plan — so the descent is proven for both statement shapes. */
29+
private const string CursorOverUdfPlan =
30+
"<ShowPlanXML xmlns=\"http://schemas.microsoft.com/sqlserver/2004/07/showplan\" Version=\"1.564\" Build=\"16.0.4135.4\">" +
31+
"<BatchSequence><Batch><Statements>" +
32+
"<StmtCursor StatementText=\"DECLARE cur CURSOR FAST_FORWARD FOR SELECT dbo.CursorFn(o.Id) FROM dbo.Orders AS o\" StatementId=\"1\" StatementCompId=\"1\">" +
33+
"<CursorPlan CursorName=\"cur\" CursorActualType=\"FastForward\" CursorRequestedType=\"FastForward\" CursorConcurrency=\"Read Only\" ForwardOnly=\"true\">" +
34+
"<Operation OperationType=\"FetchQuery\">" +
35+
"<QueryPlan>" +
36+
"<RelOp NodeId=\"0\" PhysicalOp=\"Clustered Index Scan\" LogicalOp=\"Clustered Index Scan\" EstimateRows=\"10\" EstimatedTotalSubtreeCost=\"0.005\" />" +
37+
"</QueryPlan>" +
38+
"<UDF ProcName=\"dbo.CursorFn\" IsNativelyCompiled=\"false\">" +
39+
"<Statements>" +
40+
"<StmtSimple StatementText=\"RETURN @Id * 2\" StatementId=\"2\" StatementCompId=\"2\" />" +
41+
"<StmtSimple StatementText=\"SELECT @Total = COUNT(*) FROM dbo.Numbers AS n\" StatementId=\"3\" StatementCompId=\"3\">" +
42+
"<QueryPlan>" +
43+
"<RelOp NodeId=\"0\" PhysicalOp=\"Clustered Index Scan\" LogicalOp=\"Clustered Index Scan\" EstimateRows=\"100\" EstimatedTotalSubtreeCost=\"0.02\" />" +
44+
"</QueryPlan>" +
45+
"</StmtSimple>" +
46+
"</Statements>" +
47+
"</UDF>" +
48+
"</Operation>" +
49+
"</CursorPlan>" +
50+
"</StmtCursor>" +
51+
"</Statements></Batch></BatchSequence></ShowPlanXML>";
52+
53+
/// <summary>
54+
/// The attach point, pinned the way #455 pinned the EXEC's: the cursor operation's statement
55+
/// is the one that must carry the function body, because it is the statement the operation's
56+
/// query belongs to — and it is built by a code path ParseStatement's descent never touches.
57+
/// </summary>
58+
[Fact]
59+
public void ACursorOperationStatementCarriesItsFunctionBody()
60+
{
61+
var plan = ShowPlanParser.Parse(CursorOverUdfPlan);
62+
63+
Assert.Null(plan.ParseError);
64+
var operation = Assert.Single(Assert.Single(plan.Batches).Statements);
65+
66+
// Proves this went down the cursor branch, not some fallback path.
67+
Assert.Equal("cur", operation.CursorName);
68+
69+
var udf = Assert.Single(operation.UdfPlans);
70+
Assert.Equal("dbo.CursorFn", udf.ProcName);
71+
Assert.Equal(2, udf.Statements.Count);
72+
Assert.Equal("RETURN @Id * 2", udf.Statements[0].StatementText);
73+
Assert.StartsWith("SELECT @Total", udf.Statements[1].StatementText);
74+
}
75+
76+
/// <summary>
77+
/// The shared traversal surfaces the body — which is the whole fix. Every consumer (#486)
78+
/// reads PlanStatements.EnumerateAll rather than walking batch.Statements itself, so once the
79+
/// bodies are here, the analyzer, the scorer, the mapper, and the statements grid all see
80+
/// them without any change of their own. Order matters like it did for #455: the operation
81+
/// comes first, its body follows in source order.
82+
/// </summary>
83+
[Fact]
84+
public void EnumerateAllSurfacesCursorFunctionBodyStatements()
85+
{
86+
var plan = ShowPlanParser.Parse(CursorOverUdfPlan);
87+
88+
var all = PlanStatements.EnumerateAll(plan).ToList();
89+
var operation = Assert.Single(plan.Batches.SelectMany(b => b.Statements));
90+
91+
Assert.Equal(3, all.Count);
92+
Assert.Same(operation, all[0]);
93+
Assert.Equal("RETURN @Id * 2", all[1].StatementText);
94+
Assert.StartsWith("SELECT @Total", all[2].StatementText);
95+
96+
/* The context-carrying walk names the module, so a grid row for a body statement says
97+
where it came from instead of showing a bare SELECT beside the cursor's. */
98+
var entries = PlanStatements.EnumerateAllWithContainer(plan).ToList();
99+
Assert.Null(entries[0].ContainerPath);
100+
Assert.All(entries.Skip(1), e => Assert.Equal("dbo.CursorFn", e.ContainerPath));
101+
}
102+
103+
/// <summary>
104+
/// Downstream of the traversal: the same document through analyze + score + map counts all
105+
/// three statements. TotalStatements is the number a report reader trusts first, and before
106+
/// this fix a cursor-over-UDF plan reported 1 — the #455 signature all over again, one
107+
/// container type later.
108+
/// </summary>
109+
[Fact]
110+
public void CursorFunctionBodyStatementsAreAnalyzedAndCounted()
111+
{
112+
var plan = ShowPlanParser.Parse(CursorOverUdfPlan);
113+
PlanAnalyzer.Analyze(plan);
114+
BenefitScorer.Score(plan);
115+
116+
var result = ResultMapper.Map(plan, "cursor_over_udf");
117+
118+
Assert.Equal(3, result.Summary.TotalStatements);
119+
}
120+
121+
/// <summary>
122+
/// The StoredProc half of the same read. The published XSD only puts UDF under a cursor
123+
/// Operation, but the parser reads UDF and StoredProc as a pair everywhere else it descends
124+
/// ("XSD gap" reads — plans have carried elements the schema omits before), so the cursor
125+
/// branch reads both on purpose. Pinned so the symmetric half cannot rot into untested code.
126+
/// </summary>
127+
[Fact]
128+
public void ACursorOperationStoredProcSubPlanIsReadToo()
129+
{
130+
const string xml =
131+
"<ShowPlanXML xmlns=\"http://schemas.microsoft.com/sqlserver/2004/07/showplan\">" +
132+
"<BatchSequence><Batch><Statements>" +
133+
"<StmtCursor StatementText=\"DECLARE cur CURSOR FOR SELECT 1\">" +
134+
"<CursorPlan CursorName=\"cur\"><Operation OperationType=\"FetchQuery\">" +
135+
"<QueryPlan><RelOp NodeId=\"0\" /></QueryPlan>" +
136+
"<StoredProc ProcName=\"dbo.CursorProc\"><Statements>" +
137+
"<StmtSimple StatementText=\"SELECT 2\" />" +
138+
"</Statements></StoredProc>" +
139+
"</Operation></CursorPlan></StmtCursor>" +
140+
"</Statements></Batch></BatchSequence></ShowPlanXML>";
141+
142+
var plan = ShowPlanParser.Parse(xml);
143+
144+
Assert.Null(plan.ParseError);
145+
var operation = Assert.Single(Assert.Single(plan.Batches).Statements);
146+
Assert.NotNull(operation.StoredProcPlan);
147+
Assert.Equal("dbo.CursorProc", operation.StoredProcPlan!.ProcName);
148+
Assert.Equal("SELECT 2", Assert.Single(operation.StoredProcPlan.Statements).StatementText);
149+
Assert.Equal(2, PlanStatements.EnumerateAll(plan).Count());
150+
}
151+
}

‎tests/PlanViewer.Core.Tests/ShowPlanParserLimitsTests.cs‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,68 @@ public void ProcedureNestingBelowTheDepthLimitStillParsesEveryLevel()
7474
Assert.Equal(depth, levels);
7575
}
7676

77+
/// <summary>
78+
/// #491 gave the cursor branch the same StoredProc/UDF descent as every other statement
79+
/// shape, which is a NEW descent path — and #484 is the proof that a new descent path is
80+
/// where the depth reset comes back. A plan alternating cursor and procedure shapes
81+
/// (StmtCursor &gt; CursorPlan &gt; Operation &gt; UDF wrapping StmtSimple &gt; StoredProc,
82+
/// repeated) must hit the same catchable depth error as pure procedure nesting; if the
83+
/// cursor-side descent ever forgets to pass depth, the guard can never fire across a cursor
84+
/// boundary and this parse runs to completion instead — failing on the assert, safely, per
85+
/// the big-stack reasoning on <see cref="BombDepth"/>.
86+
/// </summary>
87+
[Fact]
88+
public void CursorProcedureNestingPastTheDepthLimitFailsWithACatchableError()
89+
{
90+
var xml = NestedCursorProcedurePlan(BombDepth);
91+
92+
ParsedPlan? plan = null;
93+
var thread = new Thread(() => plan = ShowPlanParser.Parse(xml), 8 * 1024 * 1024);
94+
thread.Start();
95+
thread.Join();
96+
97+
Assert.NotNull(plan);
98+
Assert.NotNull(plan!.ParseError);
99+
Assert.Contains("depth limit", plan.ParseError);
100+
}
101+
102+
/// <summary>
103+
/// And the same in reverse for the cursor shape: carrying depth through cursor operation
104+
/// bodies must not overcount and reject legitimate nesting. Every alternating level below
105+
/// the limit still parses, attached to the right container, all the way down.
106+
/// </summary>
107+
[Fact]
108+
public void CursorProcedureNestingBelowTheDepthLimitStillParsesEveryLevel()
109+
{
110+
const int depth = 50;
111+
112+
var plan = ShowPlanParser.Parse(NestedCursorProcedurePlan(depth));
113+
114+
Assert.Null(plan.ParseError);
115+
var stmt = Assert.Single(Assert.Single(plan.Batches).Statements);
116+
var cursorLevels = 0;
117+
var procLevels = 0;
118+
while (true)
119+
{
120+
if (stmt.UdfPlans.Count == 1)
121+
{
122+
cursorLevels++;
123+
stmt = Assert.Single(stmt.UdfPlans[0].Statements);
124+
}
125+
else if (stmt.StoredProcPlan is not null)
126+
{
127+
procLevels++;
128+
stmt = Assert.Single(stmt.StoredProcPlan.Statements);
129+
}
130+
else
131+
{
132+
break;
133+
}
134+
}
135+
Assert.Equal(depth / 2, cursorLevels);
136+
Assert.Equal(depth / 2, procLevels);
137+
}
138+
77139
[Fact]
78140
public void SynchronousParseRejectsOversizedInput()
79141
{
@@ -108,4 +170,35 @@ private static string NestedProcedurePlan(int levels)
108170
xml.Append("</Statements></Batch></BatchSequence></ShowPlanXML>");
109171
return xml.ToString();
110172
}
173+
174+
/// <summary>
175+
/// Alternating levels of StmtCursor &gt; CursorPlan &gt; Operation &gt; UDF &gt; Statements
176+
/// and StmtSimple &gt; StoredProc &gt; Statements around one innermost bare statement, so the
177+
/// depth counter has to survive a hand-off between the cursor branch's descent (#491) and
178+
/// ParseStatement's (#456/#484) at every second boundary. Each cursor Operation carries the
179+
/// QueryPlan the schema requires — the branch only builds a statement (the sub-plan's attach
180+
/// point) for an operation that has one.
181+
/// </summary>
182+
private static string NestedCursorProcedurePlan(int levels)
183+
{
184+
var xml = new StringBuilder(
185+
"<ShowPlanXML xmlns=\"http://schemas.microsoft.com/sqlserver/2004/07/showplan\"><BatchSequence><Batch><Statements>");
186+
for (var level = 0; level < levels; level++)
187+
{
188+
if (level % 2 == 0)
189+
xml.Append("<StmtCursor StatementText=\"DECLARE c CURSOR\"><CursorPlan CursorName=\"c\"><Operation OperationType=\"FetchQuery\"><QueryPlan><RelOp NodeId=\"0\" /></QueryPlan><UDF ProcName=\"f\"><Statements>");
190+
else
191+
xml.Append("<StmtSimple StatementText=\"EXEC p\"><StoredProc ProcName=\"p\"><Statements>");
192+
}
193+
xml.Append("<StmtSimple StatementText=\"SELECT 1\" />");
194+
for (var level = levels - 1; level >= 0; level--)
195+
{
196+
if (level % 2 == 0)
197+
xml.Append("</Statements></UDF></Operation></CursorPlan></StmtCursor>");
198+
else
199+
xml.Append("</Statements></StoredProc></StmtSimple>");
200+
}
201+
xml.Append("</Statements></Batch></BatchSequence></ShowPlanXML>");
202+
return xml.ToString();
203+
}
111204
}

0 commit comments

Comments
 (0)