Skip to content

Commit cc18844

Browse files
Fix four analyzer rule gates (#577, #576, #579, #578)
#577: rule 5 reported a row-estimate mismatch on operators that never executed. Skip ActualExecutions = 0, as rules 11, 12 and 29 already do. #576: rule 6 dropped its scalar-UDF warning whenever the statement's NonParallelPlanReason was one rule 3 explains, even when rule 3 was disabled or gated out. Suppress it only when rule 3's Serial Plan finding is actually on the statement. #579: rules matched hints and keywords in the raw statement text, so string literals and comments counted as code. Add MaskCommentsAndLiterals and use it for rule 27 (OPTIMIZE FOR UNKNOWN) and for the same pattern in rule 3 (MAXDOP 1), rule 20 (RECOMPILE), rule 28 (NOT IN), rule 37 (cursor declaration) and the rule 26 row-goal cause. #578: rule 30 grouped missing-index suggestions by schema and table, so same-named tables in two databases looked like duplicates. Add the database to the key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR
1 parent 1ae7da5 commit cc18844

5 files changed

Lines changed: 364 additions & 12 deletions

File tree

‎src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt)
9393
{
9494
// Check statement text for NOT IN
9595
if (string.IsNullOrEmpty(stmt.StatementText) ||
96-
!Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase))
96+
!Regex.IsMatch(MaskCommentsAndLiterals(stmt.StatementText), @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) // #579
9797
return false;
9898

9999
// Walk up the tree checking ancestors and their children

‎src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,91 @@ private static string FormatNodeRef(PlanNode node)
281281
return $"{node.PhysicalOp} (Node {node.NodeId})";
282282
}
283283

284+
/* #579: the rules that look for a hint or a keyword in the query text (MAXDOP 1, RECOMPILE,
285+
OPTIMIZE FOR UNKNOWN, NOT IN, a cursor declaration, a row goal's cause) matched the raw
286+
StatementText, so the words inside a string literal or a comment counted as code. This
287+
blanks string-literal contents and whole comments with spaces, keeping every other character
288+
where it was, so a match in the result is a match in the code. The scan follows
289+
ParameterSubstitution's: '...' with doubled-quote escapes, -- to the end of the line, and
290+
block comments, which nest in T-SQL. Delimited identifiers ("..." and [...]) are stepped
291+
over unchanged, so a quote or a dash inside one does not start a string or a comment. */
292+
private static string MaskCommentsAndLiterals(string? text)
293+
{
294+
if (string.IsNullOrEmpty(text))
295+
return "";
296+
297+
var chars = text.ToCharArray();
298+
var i = 0;
299+
while (i < chars.Length)
300+
{
301+
var c = chars[i];
302+
if (c == '\'' || c == '"' || c == '[')
303+
{
304+
var close = c == '[' ? ']' : c;
305+
var end = i + 1;
306+
while (end < chars.Length)
307+
{
308+
if (chars[end] == close)
309+
{
310+
if (end + 1 < chars.Length && chars[end + 1] == close)
311+
{
312+
end += 2;
313+
continue;
314+
}
315+
break;
316+
}
317+
end++;
318+
}
319+
if (c == '\'')
320+
{
321+
for (var k = i + 1; k < end && k < chars.Length; k++)
322+
chars[k] = ' ';
323+
}
324+
i = end + 1;
325+
continue;
326+
}
327+
328+
if (c == '-' && i + 1 < chars.Length && chars[i + 1] == '-')
329+
{
330+
while (i < chars.Length && chars[i] != '\n' && chars[i] != '\r')
331+
chars[i++] = ' ';
332+
continue;
333+
}
334+
335+
if (c == '/' && i + 1 < chars.Length && chars[i + 1] == '*')
336+
{
337+
var depth = 0;
338+
while (i < chars.Length)
339+
{
340+
if (chars[i] == '/' && i + 1 < chars.Length && chars[i + 1] == '*')
341+
{
342+
depth++;
343+
chars[i++] = ' ';
344+
chars[i++] = ' ';
345+
continue;
346+
}
347+
if (chars[i] == '*' && i + 1 < chars.Length && chars[i + 1] == '/')
348+
{
349+
depth--;
350+
chars[i++] = ' ';
351+
chars[i++] = ' ';
352+
if (depth == 0)
353+
break;
354+
continue;
355+
}
356+
if (chars[i] != '\n' && chars[i] != '\r')
357+
chars[i] = ' ';
358+
i++;
359+
}
360+
continue;
361+
}
362+
363+
i++;
364+
}
365+
366+
return new string(chars);
367+
}
368+
284369
/// <summary>
285370
/// Identifies the specific cause of a row goal from the statement text.
286371
/// Returns a specific cause when detectable, or a generic list as fallback.
@@ -290,7 +375,7 @@ private static string IdentifyRowGoalCause(string stmtText)
290375
if (string.IsNullOrEmpty(stmtText))
291376
return "TOP, EXISTS, IN, or FAST hint";
292377

293-
var text = stmtText.ToUpperInvariant();
378+
var text = MaskCommentsAndLiterals(stmtText).ToUpperInvariant();
294379
var causes = new List<string>(4);
295380

296381
if (Regex.IsMatch(text, @"\bTOP\b"))

‎src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,11 @@ private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt
151151
// - A parent join may have chosen the wrong strategy
152152
// - Root nodes with no parent to harm are skipped
153153
// - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped
154+
/* #577: an operator that never executed returned zero rows because it never ran, so its
155+
zero is no evidence that the estimate was wrong. Rules 11, 12 and 29 skip such
156+
operators the same way. */
154157
if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0
158+
&& node.ActualExecutions > 0
155159
&& !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading
156160
{
157161
if (node.ActualRows == 0)
@@ -204,10 +208,15 @@ private static void Rule06_ScalarUdfReference(PlanNode node, PlanStatement stmt,
204208
// Rule 6: Scalar UDF references (works on estimated plans too)
205209
// Suppress when Serial Plan warning is already firing for a UDF-related reason —
206210
// the Serial Plan warning already explains the issue, this would be redundant.
207-
var serialPlanCoversUdf = stmt.NonParallelPlanReason is
208-
"TSQLUserDefinedFunctionsNotParallelizable"
209-
or "CLRUserDefinedFunctionRequiresDataAccess"
210-
or "CouldNotGenerateValidParallelPlan";
211+
/* #576: "already firing" has to be checked, not assumed from the reason. Rule 3 can be
212+
disabled, and it skips statements that cost under 1, TRIVIAL plans and 0 ms runs, so
213+
the UDF warning used to vanish with nothing in its place. Statement rules run before
214+
node rules, so rule 3's finding is on the statement by now if it fired. */
215+
var serialPlanCoversUdf =
216+
(stmt.NonParallelPlanReason is "TSQLUserDefinedFunctionsNotParallelizable"
217+
or "CLRUserDefinedFunctionRequiresDataAccess"
218+
or "CouldNotGenerateValidParallelPlan")
219+
&& stmt.PlanWarnings.Any(w => w.WarningType == "Serial Plan");
211220
if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf)
212221
foreach (var udf in node.ScalarUdfs)
213222
{

‎src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, Se
135135
// SQL Server truncates StatementText at ~4,000 characters in plan XML.
136136
if (stmt.NonParallelPlanReason == "MaxDOPSetToOne")
137137
{
138-
var text = stmt.StatementText ?? "";
138+
var text = MaskCommentsAndLiterals(stmt.StatementText); // #579
139139
var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase);
140140
var isTruncated = stmt.IsTextTruncated;
141141

@@ -306,7 +306,8 @@ private static void Rule20_LocalVariablesNoRecompile(PlanStatement stmt, Analyze
306306

307307
if (unsnifffedParams.Count > 0)
308308
{
309-
var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true;
309+
var hasRecompile = MaskCommentsAndLiterals(stmt.StatementText) // #579
310+
.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase);
310311
if (!hasRecompile)
311312
{
312313
var names = string.Join(", ", unsnifffedParams.Select(p => p.Name));
@@ -330,7 +331,7 @@ private static void Rule27_OptimizeForUnknown(PlanStatement stmt, AnalyzerConfig
330331
{
331332
// Rule 27: OPTIMIZE FOR UNKNOWN in statement text
332333
if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) &&
333-
Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase))
334+
Regex.IsMatch(MaskCommentsAndLiterals(stmt.StatementText), @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) // #579
334335
{
335336
stmt.PlanWarnings.Add(new PlanWarning
336337
{
@@ -377,7 +378,7 @@ private static void Rule37_CursorWithoutLocal(PlanStatement stmt, AnalyzerConfig
377378
// the SELECT. Capturing tokens *before* CURSOR never sees LOCAL and would
378379
// fire on every cursor, including ones already declared LOCAL.
379380
var cursorDeclMatch = Regex.Match(
380-
stmt.StatementText,
381+
MaskCommentsAndLiterals(stmt.StatementText), // #579
381382
@"\bDECLARE\s+\w+\s+(?:INSENSITIVE\s+|SCROLL\s+)*CURSOR\b(.*?)\bFOR\b",
382383
RegexOptions.IgnoreCase | RegexOptions.Singleline);
383384
if (cursorDeclMatch.Success)
@@ -451,16 +452,18 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi
451452
if (!cfg.IsRuleDisabled(30))
452453
{
453454
// Detect duplicate suggestions for the same table
455+
/* #578: the database is part of the key. dbo.T in database A and dbo.T in database B
456+
are two tables, and their indexes cannot be consolidated into one. */
454457
var tableSuggestionCount = stmt.MissingIndexes
455-
.GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase)
458+
.GroupBy(mi => $"{mi.Database}.{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase)
456459
.Where(g => g.Count() > 1)
457460
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
458461

459462
foreach (var mi in stmt.MissingIndexes)
460463
{
461464
var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count;
462465
var includeCount = mi.IncludeColumns.Count;
463-
var tableKey = $"{mi.Schema}.{mi.Table}";
466+
var tableKey = $"{mi.Database}.{mi.Schema}.{mi.Table}";
464467

465468
// Low-impact suggestion (< 25% improvement)
466469
if (mi.Impact < 25)

0 commit comments

Comments
 (0)