Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
04ccefc
Add "Powered by Performance Studio" line on landing page
erikdarlingdata Apr 7, 2026
678e15d
Merge pull request #186 from erikdarlingdata/fix/powered-by-line
erikdarlingdata Apr 7, 2026
6c6c1f0
Add Darling Data favicon to web app
erikdarlingdata Apr 7, 2026
a7e841c
Merge pull request #187 from erikdarlingdata/fix/favicon
erikdarlingdata Apr 7, 2026
4453034
Add Open Graph and Twitter Card meta tags for social sharing
erikdarlingdata Apr 7, 2026
482d385
Merge pull request #188 from erikdarlingdata/fix/og-meta
erikdarlingdata Apr 7, 2026
ba2beeb
Clarify OG description: in-browser, nothing to install
erikdarlingdata Apr 7, 2026
25c7648
Merge pull request #189 from erikdarlingdata/fix/og-description
erikdarlingdata Apr 7, 2026
5c5c4ff
Merge pull request #191 from erikdarlingdata/dev
erikdarlingdata Apr 7, 2026
68ff836
Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable
erikdarlingdata Apr 7, 2026
a96b465
Merge pull request #192 from erikdarlingdata/fix/rule3-actionable
erikdarlingdata Apr 7, 2026
5615021
Expand Rule 3 to cover all NonParallelPlanReason values
erikdarlingdata Apr 7, 2026
923a8e5
Merge pull request #193 from erikdarlingdata/fix/rule3-full-reasons
erikdarlingdata Apr 7, 2026
263f5a8
Merge pull request #196 from erikdarlingdata/dev
erikdarlingdata Apr 7, 2026
e8cd496
Merge pull request #199 from erikdarlingdata/dev
erikdarlingdata Apr 7, 2026
81e7285
Merge pull request #202 from erikdarlingdata/dev
erikdarlingdata Apr 7, 2026
c99311e
Merge pull request #205 from erikdarlingdata/dev
erikdarlingdata Apr 8, 2026
a3a6e5a
Merge pull request #208 from erikdarlingdata/dev
erikdarlingdata Apr 8, 2026
cb199a2
Merge pull request #210 from erikdarlingdata/dev
erikdarlingdata Apr 8, 2026
cbd0c6d
Release: issue #178 round 3 feedback (items 17-25)
erikdarlingdata Apr 9, 2026
838f40f
Merge pull request #220 from erikdarlingdata/dev
erikdarlingdata Apr 13, 2026
32ed53d
Merge pull request #223 from erikdarlingdata/dev
erikdarlingdata Apr 13, 2026
32fea96
Merge pull request #227 from erikdarlingdata/dev
erikdarlingdata Apr 15, 2026
a1f8362
Merge pull request #237 from erikdarlingdata/dev
erikdarlingdata Apr 17, 2026
2958568
Merge pull request #243 from erikdarlingdata/dev
erikdarlingdata Apr 20, 2026
ef7690c
Merge pull request #245 from erikdarlingdata/dev
erikdarlingdata Apr 21, 2026
341678f
Merge pull request #248 from erikdarlingdata/dev
erikdarlingdata Apr 21, 2026
fdf490d
Merge pull request #256 from erikdarlingdata/dev
erikdarlingdata Apr 22, 2026
f18fe57
Merge pull request #258 from erikdarlingdata/dev
erikdarlingdata Apr 22, 2026
48870b0
Merge pull request #260 from erikdarlingdata/dev
erikdarlingdata Apr 22, 2026
7009393
Merge pull request #264 from erikdarlingdata/dev
erikdarlingdata Apr 22, 2026
754b184
Merge pull request #267 from erikdarlingdata/dev
erikdarlingdata Apr 23, 2026
4cce22d
Merge pull request #269 from erikdarlingdata/dev
erikdarlingdata Apr 24, 2026
194d1fc
Merge pull request #274 from erikdarlingdata/dev
erikdarlingdata Apr 24, 2026
66be6df
Merge pull request #292 from erikdarlingdata/dev
erikdarlingdata Apr 27, 2026
02120e6
Merge pull request #309 from erikdarlingdata/dev
erikdarlingdata May 4, 2026
ce6cc37
Split PlanAnalyzer.cs into partial classes
erikdarlingdata May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 270 additions & 0 deletions src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using PlanViewer.Core.Models;

namespace PlanViewer.Core.Services;

public static partial class PlanAnalyzer
{
private static bool HasBatchModeNode(PlanNode node)
{
var mode = node.ActualExecutionMode ?? node.ExecutionMode;
if (string.Equals(mode, "Batch", StringComparison.OrdinalIgnoreCase))
return true;
foreach (var child in node.Children)
{
if (HasBatchModeNode(child))
return true;
}
return false;
}

private static void CheckForTableVariables(PlanNode node, bool isModification,
ref bool hasTableVar, ref bool modifiesTableVar)
{
if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
{
hasTableVar = true;
// The modification target is typically an Insert/Update/Delete operator on a table variable
if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
|| node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
{
modifiesTableVar = true;
}
}
foreach (var child in node.Children)
CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
}

/// <summary>
/// Detects the NOT IN with nullable column pattern: statement has NOT IN,
/// and a nearby Nested Loops Anti Semi Join has an IS NULL residual predicate.
/// Checks ancestors and their children (siblings of ancestors) since the IS NULL
/// predicate may be on a sibling Anti Semi Join rather than a direct parent.
/// </summary>
private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt)
{
// Check statement text for NOT IN
if (string.IsNullOrEmpty(stmt.StatementText) ||
!Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase))
return false;

// Walk up the tree checking ancestors and their children
var parent = spoolNode.Parent;
while (parent != null)
{
if (IsAntiSemiJoinWithIsNull(parent))
return true;

// Check siblings: the IS NULL predicate may be on a sibling Anti Semi Join
// (e.g. outer NL Anti Semi Join has two children: inner NL Anti Semi Join + Row Count Spool)
foreach (var sibling in parent.Children)
{
if (sibling != spoolNode && IsAntiSemiJoinWithIsNull(sibling))
return true;
}

parent = parent.Parent;
}

return false;
}

private static bool IsAntiSemiJoinWithIsNull(PlanNode node) =>
node.PhysicalOp == "Nested Loops" &&
node.LogicalOp.Contains("Anti Semi", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(node.Predicate) &&
node.Predicate.Contains("IS NULL", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan,
/// Table Scan). Excludes columnstore scans, spools, and constant scans.
/// </summary>
private static bool IsRowstoreScan(PlanNode node)
{
return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) &&
!node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) &&
!node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) &&
!node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Returns true when the predicate contains ONLY PROBE() bitmap filter(s)
/// with no real residual predicate. PROBE alone is a bitmap filter pushed
/// down from a hash join — not interesting by itself. If a real predicate
/// exists alongside PROBE (e.g. "[col]=(1) AND PROBE(...)"), returns false.
/// </summary>
private static bool IsProbeOnly(string predicate)
{
// Strip all PROBE(...) expressions — PROBE args can contain nested parens
var stripped = Regex.Replace(predicate, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
RegexOptions.IgnoreCase).Trim();

// Remove leftover AND/OR connectors and whitespace
stripped = Regex.Replace(stripped, @"\b(AND|OR)\b", "", RegexOptions.IgnoreCase).Trim();

// If nothing meaningful remains, it was PROBE-only
return stripped.Length == 0;
}

/// <summary>
/// Strips PROBE(...) bitmap filter expressions from a predicate for display,
/// leaving only the real residual predicate columns.
/// </summary>
private static string StripProbeExpressions(string predicate)
{
var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "",
RegexOptions.IgnoreCase);
stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
RegexOptions.IgnoreCase);
return stripped.Trim();
}

/// <summary>
/// Returns true for any scan operator including columnstore.
/// Excludes spools and constant scans.
/// </summary>
private static bool IsScanOperator(PlanNode node)
{
return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) &&
!node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) &&
!node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Detects non-SARGable patterns in scan predicates.
/// Returns a description of the issue, or null if the predicate is fine.
/// </summary>
private static string? DetectNonSargablePredicate(PlanNode node)
{
if (string.IsNullOrEmpty(node.Predicate))
return null;

// Only check rowstore scan operators — columnstore is designed to be scanned
if (!IsRowstoreScan(node))
return null;

var predicate = node.Predicate;

// CASE expression in predicate — check first because CASE bodies
// often contain CONVERT_IMPLICIT that isn't the root cause
if (CaseInPredicateRegex.IsMatch(predicate))
return "CASE expression in predicate";

// CONVERT_IMPLICIT — most common non-SARGable pattern
if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase))
return "Implicit conversion (CONVERT_IMPLICIT)";

// ISNULL / COALESCE wrapping column
if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase))
return "ISNULL/COALESCE wrapping column";

// Common function calls on columns — but only if the function wraps a column,
// not a parameter/variable. Split on comparison operators to check which side
// the function is on. Predicate format: [db].[schema].[table].[col]>func(...)
var funcMatch = FunctionInPredicateRegex.Match(predicate);
if (funcMatch.Success)
{
var funcName = funcMatch.Groups[1].Value.ToUpperInvariant();
if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch))
return $"Function call ({funcName}) on column";
}

// Leading wildcard LIKE
if (LeadingWildcardLikeRegex.IsMatch(predicate))
return "Leading wildcard LIKE pattern";

return null;
}

/// <summary>
/// Checks whether a function call in a predicate is on the column side of the comparison.
/// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var])
/// If the function is only on the parameter/literal side, it's still SARGable.
/// </summary>
private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch)
{
// Find the comparison operator that splits the predicate into left/right sides.
// Operators in ScalarString: >=, <=, <>, >, <, =
var compMatch = Regex.Match(predicate, @"(?<![<>])([<>=!]{1,2})(?![<>=])");
if (!compMatch.Success)
return true; // No comparison found — can't determine side, assume worst case

var compPos = compMatch.Index;
var funcPos = funcMatch.Index;

// Determine which side the function is on
var funcSide = funcPos < compPos ? "left" : "right";

// Check if that side also contains a column reference [...].[...].[...]
string side = funcSide == "left"
? predicate[..compPos]
: predicate[(compPos + compMatch.Length)..];

// Column references are multi-part bracket-qualified: [schema].[table].[column]
// Variables are [@var] or [@var] — single bracket pair with @ prefix.
// Match [identifier].[identifier] (at least two dotted parts) to distinguish columns.
return Regex.IsMatch(side, @"\[[^\]@]+\]\.\[");
}

/// <summary>
/// Verifies the OR expansion chain walking up from a Concatenation node:
/// Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation
/// </summary>
private static bool IsOrExpansionChain(PlanNode concatenationNode)
{
// Walk up, skipping Compute Scalar
var parent = concatenationNode.Parent;
while (parent != null && parent.PhysicalOp == "Compute Scalar")
parent = parent.Parent;

// Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort")
if (parent == null || parent.LogicalOp != "Top N Sort")
return false;

// Walk up to Merge Interval
parent = parent.Parent;
if (parent == null || parent.PhysicalOp != "Merge Interval")
return false;

// Walk up to Nested Loops
parent = parent.Parent;
if (parent == null || parent.PhysicalOp != "Nested Loops")
return false;

// If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN
// subquery pattern (Merge Interval optimizing range lookups), not an OR expansion
var nlParent = parent.Parent;
if (nlParent != null && nlParent.LogicalOp != null &&
nlParent.LogicalOp.Contains("Semi"))
return false;

return true;
}

/// <summary>
/// Finds Sort and Hash Match operators in the tree that consume memory.
/// </summary>
/// <summary>
/// Returns true if the plan contains an adaptive join that executed as a Nested Loop.
/// Indicates a memory grant was sized for the hash alternative but never needed.
/// </summary>
private static bool HasAdaptiveJoinChoseNestedLoop(PlanNode node)
{
if (node.IsAdaptive && node.ActualJoinType != null
&& node.ActualJoinType.Contains("Nested", StringComparison.OrdinalIgnoreCase))
return true;

foreach (var child in node.Children)
if (HasAdaptiveJoinChoseNestedLoop(child))
return true;

return false;
}
}
Loading
Loading