From 04ccefc7a6a1b082a7acedf7c0f07e7d732bbf27 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:43:11 -0400 Subject: [PATCH 1/7] Add "Powered by Performance Studio" line on landing page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/Pages/Index.razor | 1 + src/PlanViewer.Web/wwwroot/css/app.css | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 6aae734..3e9be24 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -6,6 +6,7 @@

Free SQL Server Query Plan Analysis

Paste or upload a .sqlplan file. Your plan XML never leaves your browser.

+

Powered by the same analysis engine in the Performance Studio app.

@if (errorMessage != null) { diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 95b0cb4..88fc17a 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -168,9 +168,25 @@ main { font-family: 'Montserrat', sans-serif; font-size: 1.05rem; font-weight: 600; + margin-bottom: 0.4rem; +} + +.powered-by { + color: var(--text-secondary); + font-size: 0.9rem; margin-bottom: 1.5rem; } +.powered-by a { + color: var(--accent); + text-decoration: none; + font-weight: 600; +} + +.powered-by a:hover { + text-decoration: underline; +} + /* === Input Area === */ .input-area { text-align: left; From 6c6c1f0e31035f6b3d755e8c9480c485a3fa22f6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:58:12 -0400 Subject: [PATCH 2/7] Add Darling Data favicon to web app Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/favicon.png | Bin 0 -> 39737 bytes src/PlanViewer.Web/wwwroot/index.html | 1 + 2 files changed, 1 insertion(+) create mode 100644 src/PlanViewer.Web/wwwroot/favicon.png diff --git a/src/PlanViewer.Web/wwwroot/favicon.png b/src/PlanViewer.Web/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..9c6db66abf67f052aaa496cf675178c8712ea1a6 GIT binary patch literal 39737 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 literal 0 HcmV?d00001 diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 543dff4..04ba38d 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,7 @@ Free SQL Server Query Plan Analysis — Darling Data + From 4453034c5ee19adc1fc32351b9ba478dc912a6a1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:03:01 -0400 Subject: [PATCH 3/7] Add Open Graph and Twitter Card meta tags for social sharing Uses the Darling Data barbell logo as hero image when shared on social media. Also adds meta description for SEO. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 04ba38d..432cfe7 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -4,6 +4,16 @@ Free SQL Server Query Plan Analysis — Darling Data + + + + + + + + + + From ba2beeb03c7861942d23702eb27bb6c2f9b4562d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:09:49 -0400 Subject: [PATCH 4/7] Clarify OG description: in-browser, nothing to install Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/wwwroot/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index 432cfe7..ebfd4ee 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -6,13 +6,13 @@ Free SQL Server Query Plan Analysis — Darling Data - + - + From 68ff836d372a3b7c73b27530c6fa7e0096a21daf Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:26:43 -0400 Subject: [PATCH 5/7] Fix Rule 3 severity: CouldNotGenerateValidParallelPlan is actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reason means something in the query blocks parallelism (scalar UDFs, table variable inserts, etc.) — that's worth a Warning, not Info. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index d89458d..af6a5a3 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -141,14 +141,16 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) _ => stmt.NonParallelPlanReason }; - // Only warn (not info) when the user explicitly forced serial execution - var isExplicit = stmt.NonParallelPlanReason is "MaxDOPSetToOne" or "QueryHintNoParallelSet"; + // Warn when the user forced serial or something in the query blocks parallelism. + // Info only for passive reasons (cost below threshold, edition limitation). + var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" + or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Serial Plan", Message = $"Query running serially: {reason}.", - Severity = isExplicit ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info }); } From 56150212e7f8f9ada43aef13bbc488e65339ed09 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:31:31 -0400 Subject: [PATCH 6/7] Expand Rule 3 to cover all NonParallelPlanReason values Adds human-readable messages for all 25 known reasons. Severity: - Warning: actionable reasons (UDFs, cursors, table variables, remote queries, trace flags, hints, DML OUTPUT, writeback variables) - Info: passive/environmental (cost below threshold, edition limits, memory-optimized tables, upgrade mode, index build edge cases) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 58 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index af6a5a3..c2b2fe6 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -133,18 +133,66 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) { var reason = stmt.NonParallelPlanReason switch { + // User/config forced serial "MaxDOPSetToOne" => "MAXDOP is set to 1", + "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "ParallelismDisabledByTraceFlag" => "Parallelism disabled by trace flag", + + // Passive — optimizer chose serial, nothing wrong "EstimatedDOPIsOne" => "Estimated DOP is 1 (the plan's estimated cost was below the cost threshold for parallelism)", + + // Edition/environment limitations "NoParallelPlansInDesktopOrExpressEdition" => "Express/Desktop edition does not support parallelism", + "NoParallelCreateIndexInNonEnterpriseEdition" => "Parallel index creation requires Enterprise edition", + "NoParallelPlansDuringUpgrade" => "Parallel plans disabled during upgrade", + "NoParallelForPDWCompilation" => "Parallel plans not supported for PDW compilation", + "NoParallelForCloudDBReplication" => "Parallel plans not supported during cloud DB replication", + + // Query constructs that block parallelism (actionable) "CouldNotGenerateValidParallelPlan" => "Optimizer could not generate a valid parallel plan. Common causes: scalar UDFs, inserts into table variables, certain system functions, or OPTION (MAXDOP 1) hints", - "QueryHintNoParallelSet" => "OPTION (MAXDOP 1) hint forces serial execution", + "TSQLUserDefinedFunctionsNotParallelizable" => "T-SQL scalar UDF prevents parallelism. Rewrite as an inline table-valued function, or on SQL Server 2019+ check if the UDF is eligible for automatic inlining", + "CLRUserDefinedFunctionRequiresDataAccess" => "CLR UDF with data access prevents parallelism", + "NonParallelizableIntrinsicFunction" => "Non-parallelizable intrinsic function in the query", + "TableVariableTransactionsDoNotSupportParallelNestedTransaction" => "Table variable transaction prevents parallelism. Consider using a #temp table instead", + "UpdatingWritebackVariable" => "Updating a writeback variable prevents parallelism", + "DMLQueryReturnsOutputToClient" => "DML with OUTPUT clause returning results to client prevents parallelism", + "MixedSerialAndParallelOnlineIndexBuildNotSupported" => "Mixed serial/parallel online index build not supported", + "NoRangesResumableCreate" => "Resumable index create cannot use parallelism for this operation", + + // Cursor limitations + "NoParallelCursorFetchByBookmark" => "Cursor fetch by bookmark cannot use parallelism", + "NoParallelDynamicCursor" => "Dynamic cursors cannot use parallelism", + "NoParallelFastForwardCursor" => "Fast-forward cursors cannot use parallelism", + + // Memory-optimized / natively compiled + "NoParallelForMemoryOptimizedTables" => "Memory-optimized tables do not support parallel plans", + "NoParallelForDmlOnMemoryOptimizedTable" => "DML on memory-optimized tables cannot use parallelism", + "NoParallelForNativelyCompiledModule" => "Natively compiled modules do not support parallelism", + + // Remote queries + "NoParallelWithRemoteQuery" => "Remote queries cannot use parallelism", + "NoRemoteParallelismForMatrix" => "Remote parallelism not available for this query shape", + _ => stmt.NonParallelPlanReason }; - // Warn when the user forced serial or something in the query blocks parallelism. - // Info only for passive reasons (cost below threshold, edition limitation). - var isActionable = stmt.NonParallelPlanReason is "MaxDOPSetToOne" - or "QueryHintNoParallelSet" or "CouldNotGenerateValidParallelPlan"; + // Actionable: user forced serial, or something in the query blocks parallelism + // that could potentially be rewritten. Info: passive (cost too low) or + // environmental (edition, upgrade, cursor type, memory-optimized). + var isActionable = stmt.NonParallelPlanReason is + "MaxDOPSetToOne" or "QueryHintNoParallelSet" or "ParallelismDisabledByTraceFlag" + or "CouldNotGenerateValidParallelPlan" + or "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "NonParallelizableIntrinsicFunction" + or "TableVariableTransactionsDoNotSupportParallelNestedTransaction" + or "UpdatingWritebackVariable" + or "DMLQueryReturnsOutputToClient" + or "NoParallelCursorFetchByBookmark" + or "NoParallelDynamicCursor" + or "NoParallelFastForwardCursor" + or "NoParallelWithRemoteQuery" + or "NoRemoteParallelismForMatrix"; stmt.PlanWarnings.Add(new PlanWarning { From 66c2e439b2ef1d0e48ce8ecf395647d71757ef9f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 13 May 2026 05:08:40 +0200 Subject: [PATCH 7/7] Split ShowPlanParser.cs into partial classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move-only refactor; no behavior changes. ShowPlanParser.cs (1,844 lines) split into 4 partials: RelOp (789) - ParseRelOp (~760-line workhorse) + GetOperatorElement + FindChildRelOps Warnings (323) - ParseWarnings + ParseWarningsFromElement + ParseMissingIndexes Costs ( 25) - ComputeOperatorCosts + ComputeNodeCosts Helpers ( 66) - CleanTempTableName, IsHexDigit, ScopedDescendants, ParseColumnList, FormatColumnRef, ParseDouble/Long Main file now 628 lines — class declaration, XML namespace, the public Parse entry, ParseStatement{,AndChildren,Attributes}, ParseQueryPlanElements / ParseQueryPlanAsStatement. PlanViewer.Web.csproj updated to link the new partial files. Build clean: 0 errors, 0 warnings on PlanViewer.sln. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/ShowPlanParser.Costs.cs | 38 + .../Services/ShowPlanParser.Helpers.cs | 84 ++ .../Services/ShowPlanParser.RelOp.cs | 803 +++++++++++ .../Services/ShowPlanParser.Warnings.cs | 337 +++++ .../Services/ShowPlanParser.cs | 1218 +---------------- src/PlanViewer.Web/PlanViewer.Web.csproj | 4 + 6 files changed, 1267 insertions(+), 1217 deletions(-) create mode 100644 src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs create mode 100644 src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs create mode 100644 src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs create mode 100644 src/PlanViewer.Core/Services/ShowPlanParser.Warnings.cs diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs new file mode 100644 index 0000000..eb48f5a --- /dev/null +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class ShowPlanParser +{ + private static void ComputeOperatorCosts(ParsedPlan plan) + { + foreach (var batch in plan.Batches) + { + foreach (var stmt in batch.Statements) + { + if (stmt.RootNode == null) continue; + var totalCost = stmt.StatementSubTreeCost > 0 + ? stmt.StatementSubTreeCost + : stmt.RootNode.EstimatedTotalSubtreeCost; + if (totalCost <= 0) totalCost = 1; + ComputeNodeCosts(stmt.RootNode, totalCost); + } + } + } + + private static void ComputeNodeCosts(PlanNode node, double totalStatementCost) + { + var childrenSubtreeCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); + node.EstimatedOperatorCost = Math.Max(0, node.EstimatedTotalSubtreeCost - childrenSubtreeCost); + node.CostPercent = (int)Math.Round((node.EstimatedOperatorCost / totalStatementCost) * 100); + node.CostPercent = Math.Min(100, Math.Max(0, node.CostPercent)); + + foreach (var child in node.Children) + ComputeNodeCosts(child, totalStatementCost); + } +} diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs new file mode 100644 index 0000000..c218928 --- /dev/null +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class ShowPlanParser +{ + /// + /// Strips the internal padding and hex session suffix from temp table names. + /// SQL Server internally pads #temp names with underscores to 116 chars, then appends a hex suffix. + /// e.g. "#comment_sil_vous_plait_______________________________0000000000A86" → "#comment_sil_vous_plait" + /// + private static string CleanTempTableName(string name) + { + if (name.Length == 0 || name[0] != '#') return name; + + // Find the end of the real name: trim trailing hex suffix, then trailing underscores + // The hex suffix is 8-16 hex chars at the end; the padding is consecutive underscores before it + var i = name.Length - 1; + + // Skip trailing hex digits (0-9, A-F, a-f) + while (i > 0 && IsHexDigit(name[i])) i--; + + // Skip trailing underscores (the padding) + while (i > 0 && name[i] == '_') i--; + + // Only clean if we actually removed a meaningful amount (at least 8 chars of padding+hex) + if (name.Length - i > 8) + return name[..(i + 1)]; + + return name; + } + + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + + private static IEnumerable ScopedDescendants(XElement element, XName name) + { + foreach (var child in element.Elements()) + { + if (child.Name == Ns + "RelOp") continue; + if (child.Name == name) yield return child; + foreach (var desc in ScopedDescendants(child, name)) + yield return desc; + } + } + + private static string? ParseColumnList(XElement parent, string elementName) + { + var el = parent.Element(Ns + elementName); + if (el == null) return null; + var cols = el.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + var result = string.Join(", ", cols); + return string.IsNullOrEmpty(result) ? null : result; + } + + private static string FormatColumnRef(XElement colRef) + { + var col = colRef.Attribute("Column")?.Value ?? ""; + var tbl = colRef.Attribute("Table")?.Value ?? ""; + var result = string.IsNullOrEmpty(tbl) ? col : $"{tbl}.{col}"; + return result.Replace("[", "").Replace("]", ""); + } + + private static double ParseDouble(string? value) + { + if (string.IsNullOrEmpty(value)) return 0; + return double.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : 0; + } + + private static long ParseLong(string? value) + { + if (string.IsNullOrEmpty(value)) return 0; + return long.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : 0; + } +} diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs new file mode 100644 index 0000000..a1d61af --- /dev/null +++ b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs @@ -0,0 +1,803 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class ShowPlanParser +{ + private static PlanNode ParseRelOp(XElement relOpEl) + { + var node = new PlanNode + { + NodeId = (int)ParseDouble(relOpEl.Attribute("NodeId")?.Value), + PhysicalOp = relOpEl.Attribute("PhysicalOp")?.Value ?? "", + LogicalOp = relOpEl.Attribute("LogicalOp")?.Value ?? "", + EstimatedTotalSubtreeCost = ParseDouble(relOpEl.Attribute("EstimatedTotalSubtreeCost")?.Value), + EstimateRows = ParseDouble(relOpEl.Attribute("EstimateRows")?.Value), + EstimateIO = ParseDouble(relOpEl.Attribute("EstimateIO")?.Value), + EstimateCPU = ParseDouble(relOpEl.Attribute("EstimateCPU")?.Value), + EstimateRebinds = ParseDouble(relOpEl.Attribute("EstimateRebinds")?.Value), + EstimateRewinds = ParseDouble(relOpEl.Attribute("EstimateRewinds")?.Value), + EstimatedRowSize = (int)ParseDouble(relOpEl.Attribute("AvgRowSize")?.Value), + Parallel = relOpEl.Attribute("Parallel")?.Value is "true" or "1", + Partitioned = relOpEl.Attribute("Partitioned")?.Value is "true" or "1", + ExecutionMode = relOpEl.Attribute("EstimatedExecutionMode")?.Value, + IsAdaptive = relOpEl.Attribute("IsAdaptive")?.Value is "true" or "1", + AdaptiveThresholdRows = ParseDouble(relOpEl.Attribute("AdaptiveThresholdRows")?.Value), + EstimatedJoinType = relOpEl.Attribute("EstimatedJoinType")?.Value, + // Wave 3.14: Estimated DOP per operator + EstimatedDOP = (int)ParseDouble(relOpEl.Attribute("EstimatedAvailableDegreeOfParallelism")?.Value), + // XSD gap: RelOp-level metadata + GroupExecuted = relOpEl.Attribute("GroupExecuted")?.Value is "true" or "1", + RemoteDataAccess = relOpEl.Attribute("RemoteDataAccess")?.Value is "true" or "1", + OptimizedHalloweenProtectionUsed = relOpEl.Attribute("OptimizedHalloweenProtectionUsed")?.Value is "true" or "1", + StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) + }; + + // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp + // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" + if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Eager " + node.PhysicalOp; + } + else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) + && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) + { + node.PhysicalOp = "Lazy " + node.PhysicalOp; + } + + + // Icon mapping is deferred until after StorageType is parsed below, + // so columnstore scans (which surface as Clustered/Index Scan with + // Storage="ColumnStore") can be routed to the columnstore icon. + + // Handle operator-specific element + var physicalOpEl = GetOperatorElement(relOpEl); + if (physicalOpEl != null) + { + // Top N Sort — XML element is but PhysicalOp is "Sort" + if (physicalOpEl.Name.LocalName == "TopSort") + node.LogicalOp = "Top N Sort"; + + // Object reference (table/index name) — scoped to stop at child RelOps + var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault(); + if (objEl != null) + { + var db = objEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = objEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = CleanTempTableName(objEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "") ?? ""); + var index = objEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + + node.DatabaseName = db; + node.IndexName = index; + + var shortParts = new List(); + if (!string.IsNullOrEmpty(schema)) shortParts.Add(schema); + if (!string.IsNullOrEmpty(table)) shortParts.Add(table); + node.ObjectName = shortParts.Count > 0 ? string.Join(".", shortParts) : null; + + var fullParts = new List(); + if (!string.IsNullOrEmpty(db)) fullParts.Add(db); + if (!string.IsNullOrEmpty(schema)) fullParts.Add(schema); + if (!string.IsNullOrEmpty(table)) fullParts.Add(table); + var fullName = string.Join(".", fullParts); + if (!string.IsNullOrEmpty(index)) + fullName += $".{index}"; + node.FullObjectName = !string.IsNullOrEmpty(fullName) ? fullName : null; + + node.StorageType = objEl.Attribute("Storage")?.Value; + node.ServerName = objEl.Attribute("Server")?.Value?.Replace("[", "").Replace("]", ""); + node.ObjectAlias = objEl.Attribute("Alias")?.Value?.Replace("[", "").Replace("]", ""); + node.IndexKind = objEl.Attribute("IndexKind")?.Value; + node.FilteredIndex = objEl.Attribute("Filtered")?.Value is "true" or "1"; + node.TableReferenceId = (int)ParseDouble(objEl.Attribute("TableReferenceId")?.Value); + } + + // Nonclustered indexes maintained by modification operators (Update/SimpleUpdate) + var opName = physicalOpEl.Name.LocalName; + if (opName is "Update" or "SimpleUpdate" or "CreateIndex") + { + var ncObjects = ScopedDescendants(physicalOpEl, Ns + "Object") + .Where(o => string.Equals(o.Attribute("IndexKind")?.Value, "NonClustered", StringComparison.OrdinalIgnoreCase)) + .ToList(); + node.NonClusteredIndexCount = ncObjects.Count; + foreach (var ncObj in ncObjects) + { + var ixName = ncObj.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + if (!string.IsNullOrEmpty(ixName)) + node.NonClusteredIndexNames.Add(ixName); + } + } + + // Hash keys for hash match operators + var hashKeysProbeEl = physicalOpEl.Element(Ns + "HashKeysProbe"); + if (hashKeysProbeEl != null) + { + var cols = hashKeysProbeEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + node.HashKeysProbe = string.Join(", ", cols); + } + var hashKeysBuildEl = physicalOpEl.Element(Ns + "HashKeysBuild"); + if (hashKeysBuildEl != null) + { + var cols = hashKeysBuildEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + node.HashKeysBuild = string.Join(", ", cols); + } + + // Ordered attribute + node.Ordered = physicalOpEl.Attribute("Ordered")?.Value == "true" || physicalOpEl.Attribute("Ordered")?.Value == "1"; + + // Seek predicates — scoped to stop at child RelOps + var seekPreds = ScopedDescendants(physicalOpEl, Ns + "SeekPredicateNew") + .Concat(ScopedDescendants(physicalOpEl, Ns + "SeekPredicate")); + var seekParts = new List(); + foreach (var sp in seekPreds) + { + foreach (var seekKeys in sp.Elements(Ns + "SeekKeys")) + { + // Each SeekKeys has Prefix, StartRange, EndRange with ScanType + foreach (var range in seekKeys.Elements()) + { + var scanType = range.Attribute("ScanType")?.Value; + var cols = range.Element(Ns + "RangeColumns")? + .Elements(Ns + "ColumnReference") + .Select(FormatColumnRef) + .ToList(); + var exprs = range.Element(Ns + "RangeExpressions")? + .Elements(Ns + "ScalarOperator") + .Select(so => so.Attribute("ScalarString")?.Value ?? "?") + .ToList(); + + if (cols != null && exprs != null) + { + var op = scanType switch + { + "EQ" => "=", "GT" => ">", "GE" => ">=", + "LT" => "<", "LE" => "<=", _ => scanType ?? "=" + }; + for (int ci = 0; ci < cols.Count && ci < exprs.Count; ci++) + seekParts.Add($"{cols[ci]} {op} {exprs[ci]}"); + } + } + } + } + if (seekParts.Count > 0) + node.SeekPredicates = string.Join(", ", seekParts); + + // GuessedSelectivity — check if optimizer guessed selectivity on predicates + if (ScopedDescendants(physicalOpEl, Ns + "GuessedSelectivity").Any()) + node.GuessedSelectivity = true; + + // Residual predicate + var predEl = physicalOpEl.Elements(Ns + "Predicate").FirstOrDefault(); + if (predEl != null) + { + var scalarOp = predEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.Predicate = scalarOp?.Attribute("ScalarString")?.Value; + } + + // Partitioning type (for parallelism operators) + node.PartitioningType = physicalOpEl.Attribute("PartitioningType")?.Value; + + // Build/Probe residuals (Hash Match) + var buildResEl = physicalOpEl.Element(Ns + "BuildResidual"); + if (buildResEl != null) + { + var so = buildResEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.BuildResidual = so?.Attribute("ScalarString")?.Value; + } + var probeResEl = physicalOpEl.Element(Ns + "ProbeResidual"); + if (probeResEl != null) + { + var so = probeResEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.ProbeResidual = so?.Attribute("ScalarString")?.Value; + } + + // Wave 2.1/2.2: Merge Residual + PassThru (Merge Join + Nested Loops) + var residualEl = physicalOpEl.Element(Ns + "Residual"); + if (residualEl != null) + { + var so = residualEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.MergeResidual = so?.Attribute("ScalarString")?.Value; + } + var passThruEl = physicalOpEl.Element(Ns + "PassThru"); + if (passThruEl != null) + { + var so = passThruEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.PassThru = so?.Attribute("ScalarString")?.Value; + } + + // OrderBy columns (Sort operator) + var orderByEl = physicalOpEl.Element(Ns + "OrderBy"); + if (orderByEl != null) + { + var obParts = orderByEl.Elements(Ns + "OrderByColumn") + .Select(obc => + { + var ascending = obc.Attribute("Ascending")?.Value != "false"; + var colRef = obc.Element(Ns + "ColumnReference"); + var name = colRef != null ? FormatColumnRef(colRef) : ""; + return string.IsNullOrEmpty(name) ? "" : $"{name} {(ascending ? "ASC" : "DESC")}"; + }) + .Where(s => !string.IsNullOrEmpty(s)); + var obStr = string.Join(", ", obParts); + if (!string.IsNullOrEmpty(obStr)) + node.OrderBy = obStr; + } + + // OuterReferences (Nested Loops) + var outerRefsEl = physicalOpEl.Element(Ns + "OuterReferences"); + if (outerRefsEl != null) + { + var refs = outerRefsEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + var refsStr = string.Join(", ", refs); + if (!string.IsNullOrEmpty(refsStr)) + node.OuterReferences = refsStr; + } + + // Inner/Outer side join columns (Merge Join) + node.InnerSideJoinColumns = ParseColumnList(physicalOpEl, "InnerSideJoinColumns"); + node.OuterSideJoinColumns = ParseColumnList(physicalOpEl, "OuterSideJoinColumns"); + + // GroupBy columns (Hash/Stream Aggregate) + node.GroupBy = ParseColumnList(physicalOpEl, "GroupBy"); + + // Partition columns (Parallelism) + node.PartitionColumns = ParseColumnList(physicalOpEl, "PartitionColumns"); + + // Wave 2.6: Parallelism HashKeys + node.HashKeys = ParseColumnList(physicalOpEl, "HashKeys"); + + // Segment column + var segColEl = physicalOpEl.Element(Ns + "SegmentColumn")?.Element(Ns + "ColumnReference"); + if (segColEl != null) + node.SegmentColumn = FormatColumnRef(segColEl); + + // Defined values (Compute Scalar) + var definedValsEl = physicalOpEl.Element(Ns + "DefinedValues"); + if (definedValsEl != null) + { + var dvParts = new List(); + foreach (var dvEl in definedValsEl.Elements(Ns + "DefinedValue")) + { + var colRef = dvEl.Element(Ns + "ColumnReference"); + var scalarOp = dvEl.Element(Ns + "ScalarOperator"); + var colName = colRef != null ? FormatColumnRef(colRef) : ""; + var expr = scalarOp?.Attribute("ScalarString")?.Value ?? ""; + if (!string.IsNullOrEmpty(colName) && !string.IsNullOrEmpty(expr)) + dvParts.Add($"{colName} = {expr}"); + else if (!string.IsNullOrEmpty(expr)) + dvParts.Add(expr); + else if (!string.IsNullOrEmpty(colName)) + dvParts.Add(colName); + } + if (dvParts.Count > 0) + node.DefinedValues = string.Join("; ", dvParts); + } + + // IndexScan / TableScan properties + node.ScanDirection = physicalOpEl.Attribute("ScanDirection")?.Value; + node.ForcedIndex = physicalOpEl.Attribute("ForcedIndex")?.Value is "true" or "1"; + node.ForceScan = physicalOpEl.Attribute("ForceScan")?.Value is "true" or "1"; + node.ForceSeek = physicalOpEl.Attribute("ForceSeek")?.Value is "true" or "1"; + node.NoExpandHint = physicalOpEl.Attribute("NoExpandHint")?.Value is "true" or "1"; + node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; + node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; + + // Override PhysicalOp, LogicalOp, and icon when Lookup=true. + // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with + // rather than "Key Lookup (Clustered)" — correct the label here so all display + // paths (node card, tooltip, properties panel) show the right operator name. + if (node.Lookup) + { + var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true + || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); + node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + } + + // Table cardinality and rows to be read (on per XSD) + node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); + node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); + node.EstimateRowsWithoutRowGoal = ParseDouble(relOpEl.Attribute("EstimateRowsWithoutRowGoal")?.Value); + if (node.EstimatedRowsRead == 0) + node.EstimatedRowsRead = node.EstimateRowsWithoutRowGoal; + + // TOP operator properties + var topExprEl = physicalOpEl.Element(Ns + "TopExpression")?.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + if (topExprEl != null) + node.TopExpression = topExprEl.Attribute("ScalarString")?.Value; + node.IsPercent = physicalOpEl.Attribute("IsPercent")?.Value is "true" or "1"; + node.WithTies = physicalOpEl.Attribute("WithTies")?.Value is "true" or "1"; + + // Wave 2.7: Top OffsetExpression, RowCount, Rows + var offsetEl = physicalOpEl.Element(Ns + "OffsetExpression")?.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + if (offsetEl != null) + node.OffsetExpression = offsetEl.Attribute("ScalarString")?.Value; + node.RowCount = physicalOpEl.Attribute("RowCount")?.Value is "true" or "1"; + node.TopRows = (int)ParseDouble(physicalOpEl.Attribute("Rows")?.Value); + + // Sort properties + node.SortDistinct = physicalOpEl.Attribute("Distinct")?.Value is "true" or "1"; + + // Filter properties + node.StartupExpression = physicalOpEl.Attribute("StartupExpression")?.Value is "true" or "1"; + + // Nested Loops properties + node.NLOptimized = physicalOpEl.Attribute("Optimized")?.Value is "true" or "1"; + node.WithOrderedPrefetch = physicalOpEl.Attribute("WithOrderedPrefetch")?.Value is "true" or "1"; + node.WithUnorderedPrefetch = physicalOpEl.Attribute("WithUnorderedPrefetch")?.Value is "true" or "1"; + + // Hash Match properties + node.ManyToMany = physicalOpEl.Attribute("ManyToMany")?.Value is "true" or "1"; + node.BitmapCreator = physicalOpEl.Attribute("BitmapCreator")?.Value is "true" or "1"; + + // Parallelism properties + node.Remoting = physicalOpEl.Attribute("Remoting")?.Value is "true" or "1"; + node.LocalParallelism = physicalOpEl.Attribute("LocalParallelism")?.Value is "true" or "1"; + + // Wave 3.8: Spool Stack + PrimaryNodeId + node.SpoolStack = physicalOpEl.Attribute("Stack")?.Value is "true" or "1"; + node.PrimaryNodeId = (int)ParseDouble(physicalOpEl.Attribute("PrimaryNodeId")?.Value); + + // Eager Index Spool — suggest CREATE INDEX from SeekPredicateNew + OutputList + if (node.LogicalOp == "Eager Spool") + { + var spoolSeek = physicalOpEl.Element(Ns + "SeekPredicateNew") + ?? physicalOpEl.Element(Ns + "SeekPredicate"); + if (spoolSeek != null) + { + var rangeCols = spoolSeek.Descendants(Ns + "RangeColumns") + .SelectMany(rc => rc.Elements(Ns + "ColumnReference")); + + var keyColumns = new List(); + string? tblSchema = null; + string? tblName = null; + + foreach (var col in rangeCols) + { + var colName = col.Attribute("Column")?.Value; + if (!string.IsNullOrEmpty(colName)) + keyColumns.Add(colName); + tblSchema ??= col.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + tblName ??= col.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + } + + if (keyColumns.Count > 0 && !string.IsNullOrEmpty(tblName)) + { + var includeCols = relOpEl.Element(Ns + "OutputList")?.Elements(Ns + "ColumnReference") + .Select(c => c.Attribute("Column")?.Value) + .Where(c => !string.IsNullOrEmpty(c) && !keyColumns.Contains(c)) + .ToList() ?? new List(); + + var prefix = !string.IsNullOrEmpty(tblSchema) ? $"{tblSchema}.{tblName}" : tblName; + var keyStr = string.Join(", ", keyColumns); + var sql = $"CREATE INDEX [{string.Join("_", keyColumns)}] ON {prefix} ({keyStr})"; + if (includeCols.Count > 0) + sql += $" INCLUDE ({string.Join(", ", includeCols)})"; + sql += ";"; + node.SuggestedIndex = sql; + } + } + } + + // Wave 3.9: Update DMLRequestSort + ActionColumn + node.DMLRequestSort = physicalOpEl.Attribute("DMLRequestSort")?.Value is "true" or "1"; + var actionColEl = physicalOpEl.Element(Ns + "ActionColumn")?.Element(Ns + "ColumnReference"); + if (actionColEl != null) + node.ActionColumn = FormatColumnRef(actionColEl); + + // SET predicate (UPDATE operator) + var setPredicateEl = physicalOpEl.Element(Ns + "SetPredicate"); + if (setPredicateEl != null) + { + var so = setPredicateEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); + node.SetPredicate = so?.Attribute("ScalarString")?.Value; + } + + // ActualJoinType from runtime info on adaptive joins + node.ActualJoinType = physicalOpEl.Attribute("ActualJoinType")?.Value; + + // XSD gap: ForceSeekColumnCount (IndexScan) + node.ForceSeekColumnCount = (int)ParseDouble(physicalOpEl.Attribute("ForceSeekColumnCount")?.Value); + + // XSD gap: PartitionId (IndexScan, TableScan, Sort, NestedLoops, AdaptiveJoin) + var partitionIdEl = physicalOpEl.Element(Ns + "PartitionId"); + if (partitionIdEl != null) + { + var pidCols = partitionIdEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + var pidStr = string.Join(", ", pidCols); + if (!string.IsNullOrEmpty(pidStr)) + node.PartitionId = pidStr; + } + + // XSD gap: StarJoinInfo (Hash, Merge, NL, AdaptiveJoin) + var starJoinEl = physicalOpEl.Element(Ns + "StarJoinInfo"); + if (starJoinEl != null) + { + node.IsStarJoin = starJoinEl.Attribute("Root")?.Value is "true" or "1"; + node.StarJoinOperationType = starJoinEl.Attribute("OperationType")?.Value; + } + + // XSD gap: ProbeColumn (NL, Parallelism, Update) + var probeColEl = physicalOpEl.Element(Ns + "ProbeColumn")?.Element(Ns + "ColumnReference"); + if (probeColEl != null) + node.ProbeColumn = FormatColumnRef(probeColEl); + + // XSD gap: InRow (Parallelism) + node.InRow = physicalOpEl.Attribute("InRow")?.Value is "true" or "1"; + + // XSD gap: ComputeSequence (ComputeScalar) + node.ComputeSequence = physicalOpEl.Attribute("ComputeSequence")?.Value is "true" or "1"; + + // XSD gap: RollupInfo (StreamAggregate) + var rollupEl = physicalOpEl.Element(Ns + "RollupInfo"); + if (rollupEl != null) + { + node.RollupHighestLevel = (int)ParseDouble(rollupEl.Attribute("HighestLevel")?.Value); + foreach (var rlEl in rollupEl.Elements(Ns + "RollupLevel")) + node.RollupLevels.Add((int)ParseDouble(rlEl.Attribute("Level")?.Value)); + } + + // XSD gap: TVF ParameterList + var tvfParamListEl = physicalOpEl.Element(Ns + "ParameterList"); + if (tvfParamListEl != null) + { + var tvfCols = tvfParamListEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + var tvfStr = string.Join(", ", tvfCols); + if (!string.IsNullOrEmpty(tvfStr)) + node.TvfParameters = tvfStr; + // Also check for ScalarOperator children (TVF can have scalar params) + if (string.IsNullOrEmpty(node.TvfParameters)) + { + var tvfScalars = tvfParamListEl.Elements(Ns + "ScalarOperator") + .Select(s => s.Attribute("ScalarString")?.Value) + .Where(s => !string.IsNullOrEmpty(s)); + var tvfScalarStr = string.Join(", ", tvfScalars); + if (!string.IsNullOrEmpty(tvfScalarStr)) + node.TvfParameters = tvfScalarStr; + } + } + + // XSD gap: OriginalActionColumn (Update) + var origActionColEl = physicalOpEl.Element(Ns + "OriginalActionColumn")?.Element(Ns + "ColumnReference"); + if (origActionColEl != null) + node.OriginalActionColumn = FormatColumnRef(origActionColEl); + + // XSD gap: Scalar UDF structured detection + foreach (var udfEl in ScopedDescendants(physicalOpEl, Ns + "UserDefinedFunction")) + { + var udfRef = new ScalarUdfReference + { + FunctionName = udfEl.Attribute("FunctionName")?.Value?.Replace("[", "").Replace("]", "") ?? "", + IsClrFunction = udfEl.Attribute("IsClrFunction")?.Value is "true" or "1" + }; + var clrEl = udfEl.Element(Ns + "CLRFunction"); + if (clrEl != null) + { + udfRef.ClrAssembly = clrEl.Attribute("Assembly")?.Value; + udfRef.ClrClass = clrEl.Attribute("Class")?.Value; + udfRef.ClrMethod = clrEl.Attribute("Method")?.Value; + } + if (!string.IsNullOrEmpty(udfRef.FunctionName)) + node.ScalarUdfs.Add(udfRef); + } + + // XSD gap: TieColumns (Top operator) + node.TieColumns = ParseColumnList(physicalOpEl, "TieColumns"); + + // XSD gap: UDXName (Extension operator) + node.UdxName = physicalOpEl.Attribute("UDXName")?.Value; + + // XSD gap: Operator-level IndexedViewInfo + var opIvInfoEl = physicalOpEl.Element(Ns + "IndexedViewInfo"); + if (opIvInfoEl != null) + { + foreach (var ivObjEl in opIvInfoEl.Elements(Ns + "Object")) + { + var ivDb = ivObjEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var ivSchema = ivObjEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var ivTable = ivObjEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var ivIndex = ivObjEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var ivParts = new List(); + if (!string.IsNullOrEmpty(ivDb)) ivParts.Add(ivDb); + if (!string.IsNullOrEmpty(ivSchema)) ivParts.Add(ivSchema); + if (!string.IsNullOrEmpty(ivTable)) ivParts.Add(ivTable); + if (!string.IsNullOrEmpty(ivIndex)) ivParts.Add(ivIndex); + var ivName = string.Join(".", ivParts); + if (!string.IsNullOrEmpty(ivName)) + node.OperatorIndexedViews.Add(ivName); + } + } + + // XSD gap: NamedParameterList (IndexScan) + var namedParamListEl = physicalOpEl.Element(Ns + "NamedParameterList"); + if (namedParamListEl != null) + { + foreach (var npEl in namedParamListEl.Elements(Ns + "NamedParameter")) + { + var np = new NamedParameterInfo + { + Name = npEl.Attribute("Name")?.Value ?? "" + }; + var npScalar = npEl.Element(Ns + "ScalarOperator"); + if (npScalar != null) + np.ScalarString = npScalar.Attribute("ScalarString")?.Value; + if (!string.IsNullOrEmpty(np.Name)) + node.NamedParameters.Add(np); + } + } + + // XSD gap: Remote operator metadata + node.RemoteDestination = physicalOpEl.Attribute("RemoteDestination")?.Value; + node.RemoteSource = physicalOpEl.Attribute("RemoteSource")?.Value; + node.RemoteObject = physicalOpEl.Attribute("RemoteObject")?.Value; + node.RemoteQuery = physicalOpEl.Attribute("RemoteQuery")?.Value; + + // ForeignKeyReferenceCheck attributes + node.ForeignKeyReferencesCount = (int)ParseDouble(physicalOpEl.Attribute("ForeignKeyReferencesCount")?.Value); + node.NoMatchingIndexCount = (int)ParseDouble(physicalOpEl.Attribute("NoMatchingIndexCount")?.Value); + node.PartialMatchingIndexCount = (int)ParseDouble(physicalOpEl.Attribute("PartialMatchingIndexCount")?.Value); + + // ConstantScan Values — parse Values/Row/ScalarOperator children + var valuesEl = physicalOpEl.Element(Ns + "Values"); + if (valuesEl != null) + { + var rowParts = new List(); + foreach (var rowEl in valuesEl.Elements(Ns + "Row")) + { + var scalars = rowEl.Elements(Ns + "ScalarOperator") + .Select(s => s.Attribute("ScalarString")?.Value ?? "") + .Where(s => !string.IsNullOrEmpty(s)); + var rowStr = string.Join(", ", scalars); + if (!string.IsNullOrEmpty(rowStr)) + rowParts.Add($"({rowStr})"); + } + if (rowParts.Count > 0) + node.ConstantScanValues = string.Join(", ", rowParts); + } + + // UDX UsedUDXColumns — column references for CLR aggregate operators + var udxColsEl = physicalOpEl.Element(Ns + "UsedUDXColumns"); + if (udxColsEl != null) + { + var udxCols = udxColsEl.Elements(Ns + "ColumnReference") + .Select(c => FormatColumnRef(c)) + .Where(s => !string.IsNullOrEmpty(s)); + var udxColStr = string.Join(", ", udxCols); + if (!string.IsNullOrEmpty(udxColStr)) + node.UdxUsedColumns = udxColStr; + } + } + + // Output columns + var outputList = relOpEl.Element(Ns + "OutputList"); + if (outputList != null) + { + var cols = outputList.Elements(Ns + "ColumnReference") + .Select(c => + { + var col = c.Attribute("Column")?.Value ?? ""; + var tbl = c.Attribute("Table")?.Value ?? ""; + return string.IsNullOrEmpty(tbl) ? col : $"{tbl}.{col}"; + }) + .Where(s => !string.IsNullOrEmpty(s)); + var colList = string.Join(", ", cols); + if (!string.IsNullOrEmpty(colList)) + node.OutputColumns = colList.Replace("[", "").Replace("]", ""); + } + + // Warnings + node.Warnings = ParseWarnings(relOpEl); + + // SpillOccurred detail flag (node-level boolean) + var warningsCheckEl = relOpEl.Element(Ns + "Warnings"); + if (warningsCheckEl?.Element(Ns + "SpillOccurred") != null) + node.SpillOccurredDetail = true; + + // Wave 3.2: MemoryFractions (on RelOp) + var memFracEl = relOpEl.Element(Ns + "MemoryFractions"); + if (memFracEl != null) + { + node.MemoryFractionInput = ParseDouble(memFracEl.Attribute("Input")?.Value); + node.MemoryFractionOutput = ParseDouble(memFracEl.Attribute("Output")?.Value); + } + + // Wave 3.3: RunTimePartitionSummary (on RelOp) + var rtPartEl = relOpEl.Element(Ns + "RunTimePartitionSummary"); + if (rtPartEl != null) + { + var partAccEl = rtPartEl.Element(Ns + "PartitionsAccessed"); + if (partAccEl != null) + { + node.PartitionsAccessed = (int)ParseDouble(partAccEl.Attribute("PartitionCount")?.Value); + var ranges = partAccEl.Elements(Ns + "PartitionRange") + .Select(r => $"{r.Attribute("Start")?.Value}-{r.Attribute("End")?.Value}"); + node.PartitionRanges = string.Join(", ", ranges); + if (string.IsNullOrEmpty(node.PartitionRanges)) + node.PartitionRanges = null; + } + } + + // Wave 2.4: Per-operator memory grants (MemoryGrant on RelOp) + var memGrantEl = relOpEl.Element(Ns + "MemoryGrant"); + if (memGrantEl != null) + { + node.MemoryGrantKB = ParseLong(memGrantEl.Attribute("GrantedMemory")?.Value); + node.DesiredMemoryKB = ParseLong(memGrantEl.Attribute("DesiredMemory")?.Value); + node.MaxUsedMemoryKB = ParseLong(memGrantEl.Attribute("MaxUsedMemory")?.Value); + } + + // Runtime information (actual plan) + var runtimeEl = relOpEl.Element(Ns + "RunTimeInformation"); + if (runtimeEl != null) + { + node.HasActualStats = true; + long totalRows = 0, totalExecutions = 0, totalRowsRead = 0; + long totalRebinds = 0, totalRewinds = 0; + long maxElapsed = 0, totalCpu = 0; + long totalLogicalReads = 0, totalPhysicalReads = 0; + long totalScans = 0, totalReadAheads = 0; + long totalLobLogicalReads = 0, totalLobPhysicalReads = 0, totalLobReadAheads = 0; + long totalSegmentReads = 0, totalSegmentSkips = 0; + long totalUdfCpu = 0, maxUdfElapsed = 0; + long maxInputMemoryGrant = 0, maxOutputMemoryGrant = 0, maxUsedMemoryGrant = 0; + string? actualExecMode = null; + + foreach (var thread in runtimeEl.Elements(Ns + "RunTimeCountersPerThread")) + { + totalRows += ParseLong(thread.Attribute("ActualRows")?.Value); + totalExecutions += ParseLong(thread.Attribute("ActualExecutions")?.Value); + totalRowsRead += ParseLong(thread.Attribute("ActualRowsRead")?.Value); + totalRebinds += ParseLong(thread.Attribute("ActualRebinds")?.Value); + totalRewinds += ParseLong(thread.Attribute("ActualRewinds")?.Value); + totalCpu += ParseLong(thread.Attribute("ActualCPUms")?.Value); + totalLogicalReads += ParseLong(thread.Attribute("ActualLogicalReads")?.Value); + totalPhysicalReads += ParseLong(thread.Attribute("ActualPhysicalReads")?.Value); + totalScans += ParseLong(thread.Attribute("ActualScans")?.Value); + totalReadAheads += ParseLong(thread.Attribute("ActualReadAheads")?.Value); + totalLobLogicalReads += ParseLong(thread.Attribute("ActualLobLogicalReads")?.Value); + totalLobPhysicalReads += ParseLong(thread.Attribute("ActualLobPhysicalReads")?.Value); + totalLobReadAheads += ParseLong(thread.Attribute("ActualLobReadAheads")?.Value); + + // Wave 3.10: Columnstore segment reads/skips + totalSegmentReads += ParseLong(thread.Attribute("ActualSegmentReads")?.Value); + totalSegmentSkips += ParseLong(thread.Attribute("ActualSegmentSkips")?.Value); + + // Wave 3.11: UDF timing + totalUdfCpu += ParseLong(thread.Attribute("UdfCpuTime")?.Value); + var udfElapsed = ParseLong(thread.Attribute("UdfElapsedTime")?.Value); + if (udfElapsed > maxUdfElapsed) maxUdfElapsed = udfElapsed; + + // Per-operator memory grant (same value on all threads, take max) + var inputMem = ParseLong(thread.Attribute("InputMemoryGrant")?.Value); + var outputMem = ParseLong(thread.Attribute("OutputMemoryGrant")?.Value); + var usedMem = ParseLong(thread.Attribute("UsedMemoryGrant")?.Value); + if (inputMem > maxInputMemoryGrant) maxInputMemoryGrant = inputMem; + if (outputMem > maxOutputMemoryGrant) maxOutputMemoryGrant = outputMem; + if (usedMem > maxUsedMemoryGrant) maxUsedMemoryGrant = usedMem; + + actualExecMode ??= thread.Attribute("ActualExecutionMode")?.Value; + + var elapsed = ParseLong(thread.Attribute("ActualElapsedms")?.Value); + if (elapsed > maxElapsed) maxElapsed = elapsed; + } + + node.ActualRows = totalRows; + node.ActualExecutions = totalExecutions; + node.ActualRowsRead = totalRowsRead; + node.ActualRebinds = totalRebinds; + node.ActualRewinds = totalRewinds; + node.ActualElapsedMs = maxElapsed; + node.ActualCPUMs = totalCpu; + node.ActualLogicalReads = totalLogicalReads; + node.ActualPhysicalReads = totalPhysicalReads; + node.ActualScans = totalScans; + node.ActualReadAheads = totalReadAheads; + node.ActualLobLogicalReads = totalLobLogicalReads; + node.ActualLobPhysicalReads = totalLobPhysicalReads; + node.ActualLobReadAheads = totalLobReadAheads; + node.ActualExecutionMode = actualExecMode; + node.ActualSegmentReads = totalSegmentReads; + node.ActualSegmentSkips = totalSegmentSkips; + node.UdfCpuTimeMs = totalUdfCpu; + node.UdfElapsedTimeMs = maxUdfElapsed; + node.InputMemoryGrantKB = maxInputMemoryGrant; + node.OutputMemoryGrantKB = maxOutputMemoryGrant; + node.UsedMemoryGrantKB = maxUsedMemoryGrant; + + // Store per-thread data for parallel skew analysis + foreach (var thread in runtimeEl.Elements(Ns + "RunTimeCountersPerThread")) + { + node.PerThreadStats.Add(new PerThreadRuntimeInfo + { + ThreadId = (int)ParseDouble(thread.Attribute("Thread")?.Value), + ActualRows = ParseLong(thread.Attribute("ActualRows")?.Value), + ActualExecutions = ParseLong(thread.Attribute("ActualExecutions")?.Value), + ActualElapsedMs = ParseLong(thread.Attribute("ActualElapsedms")?.Value), + ActualCPUMs = ParseLong(thread.Attribute("ActualCPUms")?.Value), + ActualRowsRead = ParseLong(thread.Attribute("ActualRowsRead")?.Value), + ActualLogicalReads = ParseLong(thread.Attribute("ActualLogicalReads")?.Value), + ActualPhysicalReads = ParseLong(thread.Attribute("ActualPhysicalReads")?.Value), + ActualScans = ParseLong(thread.Attribute("ActualScans")?.Value), + ActualReadAheads = ParseLong(thread.Attribute("ActualReadAheads")?.Value), + FirstActiveTime = ParseLong(thread.Attribute("FirstActiveTime")?.Value), + LastActiveTime = ParseLong(thread.Attribute("LastActiveTime")?.Value), + OpenTime = ParseLong(thread.Attribute("OpenTime")?.Value), + FirstRowTime = ParseLong(thread.Attribute("FirstRowTime")?.Value), + LastRowTime = ParseLong(thread.Attribute("LastRowTime")?.Value), + CloseTime = ParseLong(thread.Attribute("CloseTime")?.Value), + InputMemoryGrant = ParseLong(thread.Attribute("InputMemoryGrant")?.Value), + OutputMemoryGrant = ParseLong(thread.Attribute("OutputMemoryGrant")?.Value), + UsedMemoryGrant = ParseLong(thread.Attribute("UsedMemoryGrant")?.Value), + Batches = ParseLong(thread.Attribute("Batches")?.Value), + ActualEndOfScans = ParseLong(thread.Attribute("ActualEndOfScans")?.Value), + ActualLocallyAggregatedRows = ParseLong(thread.Attribute("ActualLocallyAggregatedRows")?.Value), + IsInterleavedExecuted = thread.Attribute("IsInterleavedExecuted")?.Value is "true" or "1", + RowRequalifications = ParseLong(thread.Attribute("RowRequalifications")?.Value) + }); + } + } + + // Map to icon — done here so columnstore scans (Clustered/Index Scan + // with Storage="ColumnStore") and Parallelism subtypes (which depend on + // LogicalOp) can be routed to their specific icons. + node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp, node.StorageType, node.LogicalOp); + + // Recurse into child RelOps + foreach (var childRelOp in FindChildRelOps(relOpEl)) + { + var childNode = ParseRelOp(childRelOp); + childNode.Parent = node; + node.Children.Add(childNode); + } + + return node; + } + + private static XElement? GetOperatorElement(XElement relOpEl) + { + foreach (var child in relOpEl.Elements()) + { + var name = child.Name.LocalName; + if (name != "OutputList" && name != "RunTimeInformation" && name != "Warnings" + && name != "MemoryFractions" && name != "RunTimePartitionSummary" + && name != "MemoryGrant" && name != "InternalInfo") + { + return child; + } + } + return null; + } + + private static IEnumerable FindChildRelOps(XElement relOpEl) + { + var operatorEl = GetOperatorElement(relOpEl); + if (operatorEl == null) yield break; + + foreach (var child in operatorEl.Elements(Ns + "RelOp")) + yield return child; + + foreach (var child in operatorEl.Elements()) + { + if (child.Name.LocalName == "RelOp") continue; + foreach (var nestedRelOp in child.Elements(Ns + "RelOp")) + yield return nestedRelOp; + } + } +} diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Warnings.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Warnings.cs new file mode 100644 index 0000000..5fe1ca1 --- /dev/null +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Warnings.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +public static partial class ShowPlanParser +{ + private static List ParseMissingIndexes(XElement queryPlanEl) + { + var result = new List(); + var missingIndexesEl = queryPlanEl.Element(Ns + "MissingIndexes"); + if (missingIndexesEl == null) return result; + + foreach (var groupEl in missingIndexesEl.Elements(Ns + "MissingIndexGroup")) + { + var impact = ParseDouble(groupEl.Attribute("Impact")?.Value); + foreach (var indexEl in groupEl.Elements(Ns + "MissingIndex")) + { + var mi = new MissingIndex + { + Database = indexEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", "") ?? "", + Schema = indexEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", "") ?? "", + Table = CleanTempTableName(indexEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "") ?? ""), + Impact = impact + }; + + foreach (var colGroup in indexEl.Elements(Ns + "ColumnGroup")) + { + var usage = colGroup.Attribute("Usage")?.Value ?? ""; + var cols = colGroup.Elements(Ns + "Column") + .Select(c => c.Attribute("Name")?.Value?.Replace("[", "").Replace("]", "") ?? "") + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + + switch (usage) + { + case "EQUALITY": mi.EqualityColumns = cols; break; + case "INEQUALITY": mi.InequalityColumns = cols; break; + case "INCLUDE": mi.IncludeColumns = cols; break; + } + } + + var keyCols = mi.EqualityColumns.Concat(mi.InequalityColumns).ToList(); + if (keyCols.Count > 0) + { + var quotedKeyCols = keyCols.Select(c => $"[{c}]"); + var create = $"CREATE NONCLUSTERED INDEX [{mi.Table}_{string.Join("_", keyCols.Take(3))}]\nON [{mi.Schema}].[{mi.Table}] ({string.Join(", ", quotedKeyCols)})"; + if (mi.IncludeColumns.Count > 0) + { + var quotedIncludes = mi.IncludeColumns.Select(c => $"[{c}]"); + create += $"\nINCLUDE ({string.Join(", ", quotedIncludes)})"; + } + create += ";"; + mi.CreateStatement = create; + } + + result.Add(mi); + } + } + return result; + } + + /// + /// Parse warnings from a parent element that contains a <Warnings> child (e.g. RelOp). + /// + private static List ParseWarnings(XElement parentEl) + { + var warningsEl = parentEl.Element(Ns + "Warnings"); + if (warningsEl == null) return new List(); + return ParseWarningsFromElement(warningsEl); + } + + /// + /// Parse warnings directly from a <Warnings> element. + /// + private static List ParseWarningsFromElement(XElement warningsEl) + { + var result = new List(); + + // No join predicate + if (warningsEl.Attribute("NoJoinPredicate")?.Value is "true" or "1") + { + result.Add(new PlanWarning + { + WarningType = "No Join Predicate", + Message = "This join triggered a no join predicate warning, which is worth checking on, but is often misleading. The optimizer may have removed a redundant predicate after simplification.", + Severity = PlanWarningSeverity.Warning + }); + } + + if (warningsEl.Attribute("SpatialGuess")?.Value is "true" or "1") + { + result.Add(new PlanWarning + { + WarningType = "Spatial Guess", + Message = "Spatial index selectivity was guessed", + Severity = PlanWarningSeverity.Info + }); + } + + if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") + { + // Parse child UnmatchedIndexes detail if present + var unmatchedMsg = "Indexes could not be matched due to parameterization"; + var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); + if (unmatchedEl != null) + { + var unmatchedDetails = new List(); + foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) + { + var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); + var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); + var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); + var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); + var parts = new List(); + if (!string.IsNullOrEmpty(db)) parts.Add(db); + if (!string.IsNullOrEmpty(schema)) parts.Add(schema); + if (!string.IsNullOrEmpty(table)) parts.Add(table); + if (!string.IsNullOrEmpty(index)) parts.Add(index); + if (parts.Count > 0) + unmatchedDetails.Add(string.Join(".", parts)); + } + if (unmatchedDetails.Count > 0) + unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); + } + result.Add(new PlanWarning + { + WarningType = "Unmatched Indexes", + Message = unmatchedMsg, + Severity = PlanWarningSeverity.Warning + }); + } + + if (warningsEl.Attribute("FullUpdateForOnlineIndexBuild")?.Value is "true" or "1") + { + result.Add(new PlanWarning + { + WarningType = "Full Update for Online Index Build", + Message = "Full update required for online index build operation", + Severity = PlanWarningSeverity.Info + }); + } + + // Spill to TempDb — collect SpillToTempDb level/thread info first + var spillLevel = ""; + var spillThreads = ""; + var spillToTempDbEl = warningsEl.Element(Ns + "SpillToTempDb"); + if (spillToTempDbEl != null) + { + spillLevel = spillToTempDbEl.Attribute("SpillLevel")?.Value ?? "?"; + spillThreads = spillToTempDbEl.Attribute("SpilledThreadCount")?.Value ?? "?"; + } + + // Sort spill details — merged with SpillToTempDb level/thread info + foreach (var sortSpillEl in warningsEl.Elements(Ns + "SortSpillDetails")) + { + var granted = ParseLong(sortSpillEl.Attribute("GrantedMemoryKb")?.Value); + var used = ParseLong(sortSpillEl.Attribute("UsedMemoryKb")?.Value); + var writes = ParseLong(sortSpillEl.Attribute("WritesToTempDb")?.Value); + var reads = ParseLong(sortSpillEl.Attribute("ReadsFromTempDb")?.Value); + var prefix = spillLevel != "" ? $"Sort spill level {spillLevel}, {spillThreads} thread(s)" : "Sort spill"; + result.Add(new PlanWarning + { + WarningType = "Sort Spill", + Message = $"{prefix} — Granted: {granted:N0} KB, Used: {used:N0} KB, Writes: {writes:N0}, Reads: {reads:N0}", + Severity = PlanWarningSeverity.Warning, + SpillDetails = new SpillDetail + { + SpillType = "Sort", + GrantedMemoryKB = granted, + UsedMemoryKB = used, + WritesToTempDb = writes, + ReadsFromTempDb = reads + } + }); + } + + // Hash spill details — merged with SpillToTempDb level/thread info + foreach (var hashSpillEl in warningsEl.Elements(Ns + "HashSpillDetails")) + { + var granted = ParseLong(hashSpillEl.Attribute("GrantedMemoryKb")?.Value); + var used = ParseLong(hashSpillEl.Attribute("UsedMemoryKb")?.Value); + var writes = ParseLong(hashSpillEl.Attribute("WritesToTempDb")?.Value); + var reads = ParseLong(hashSpillEl.Attribute("ReadsFromTempDb")?.Value); + var prefix = spillLevel != "" ? $"Hash spill level {spillLevel}, {spillThreads} thread(s)" : "Hash spill"; + result.Add(new PlanWarning + { + WarningType = "Hash Spill", + Message = $"{prefix} — Granted: {granted:N0} KB, Used: {used:N0} KB, Writes: {writes:N0}, Reads: {reads:N0}", + Severity = PlanWarningSeverity.Warning, + SpillDetails = new SpillDetail + { + SpillType = "Hash", + GrantedMemoryKB = granted, + UsedMemoryKB = used, + WritesToTempDb = writes, + ReadsFromTempDb = reads + } + }); + } + + // Standalone SpillToTempDb — only emit if no Sort/Hash detail element consumed it + if (spillToTempDbEl != null && + !warningsEl.Elements(Ns + "SortSpillDetails").Any() && + !warningsEl.Elements(Ns + "HashSpillDetails").Any()) + { + var msg = $"Spill level {spillLevel}, {spillThreads} thread(s)"; + var grantedKB = ParseLong(spillToTempDbEl.Attribute("GrantedMemoryKB")?.Value); + var usedKB = ParseLong(spillToTempDbEl.Attribute("UsedMemoryKB")?.Value); + var writes = ParseLong(spillToTempDbEl.Attribute("WritesToTempDb")?.Value); + var reads = ParseLong(spillToTempDbEl.Attribute("ReadsFromTempDb")?.Value); + if (grantedKB > 0 || writes > 0) + { + msg += $" — Granted: {grantedKB:N0} KB, Used: {usedKB:N0} KB"; + if (writes > 0) msg += $", Writes: {writes:N0}"; + if (reads > 0) msg += $", Reads: {reads:N0}"; + } + + result.Add(new PlanWarning + { + WarningType = "Spill to TempDb", + Message = msg, + Severity = PlanWarningSeverity.Warning + }); + } + + // Exchange spill details + foreach (var exchSpillEl in warningsEl.Elements(Ns + "ExchangeSpillDetails")) + { + result.Add(new PlanWarning + { + WarningType = "Exchange Spill", + Message = $"Exchange spill — {ParseLong(exchSpillEl.Attribute("WritesToTempDb")?.Value):N0} writes to TempDB. The parallel exchange operator ran out of memory buffers and spilled rows to disk. This typically means the memory grant was too small for the data volume flowing through this exchange.", + Severity = PlanWarningSeverity.Warning, + SpillDetails = new SpillDetail + { + SpillType = "Exchange", + WritesToTempDb = ParseLong(exchSpillEl.Attribute("WritesToTempDb")?.Value) + } + }); + } + + // SpillOccurred + var spillOccurredEl = warningsEl.Element(Ns + "SpillOccurred"); + if (spillOccurredEl != null) + { + result.Add(new PlanWarning + { + WarningType = "Spill Occurred", + Message = "Spill occurred during execution (from last query plan stats)", + Severity = PlanWarningSeverity.Warning + }); + } + + // Memory grant warning (from plan XML) — gate at 1 GB to avoid noise on small grants + // All values are in KB, consistent with MemoryGrantInfo element + var memWarnEl = warningsEl.Element(Ns + "MemoryGrantWarning"); + if (memWarnEl != null) + { + var kind = memWarnEl.Attribute("GrantWarningKind")?.Value ?? "Unknown"; + var requested = ParseLong(memWarnEl.Attribute("RequestedMemory")?.Value); + var granted = ParseLong(memWarnEl.Attribute("GrantedMemory")?.Value); + var maxUsed = ParseLong(memWarnEl.Attribute("MaxUsedMemory")?.Value); + if (granted >= 1048576) // 1 GB in KB + { + var grantedMB = granted / 1024.0; + var usedMB = maxUsed / 1024.0; + result.Add(new PlanWarning + { + WarningType = "Memory Grant", + Message = $"{kind}: Granted {grantedMB:N0} MB, Used {usedMB:N0} MB", + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Implicit conversions + foreach (var convertEl in warningsEl.Elements(Ns + "PlanAffectingConvert")) + { + var issue = convertEl.Attribute("ConvertIssue")?.Value ?? "Unknown"; + var expr = convertEl.Attribute("Expression")?.Value ?? ""; + result.Add(new PlanWarning + { + WarningType = "Implicit Conversion", + Message = $"{issue}: {expr}", + Severity = issue.Contains("Cardinality") ? PlanWarningSeverity.Warning : PlanWarningSeverity.Critical + }); + } + + // Columns with no statistics + var noStatsEl = warningsEl.Element(Ns + "ColumnsWithNoStatistics"); + if (noStatsEl != null) + { + var cols = noStatsEl.Elements(Ns + "ColumnReference") + .Select(c => c.Attribute("Column")?.Value ?? "") + .Where(s => !string.IsNullOrEmpty(s)); + result.Add(new PlanWarning + { + WarningType = "Missing Statistics", + Message = $"No statistics on: {string.Join(", ", cols)}", + Severity = PlanWarningSeverity.Warning + }); + } + + // Wave 2.3: Columns with stale statistics + var staleStatsEl = warningsEl.Element(Ns + "ColumnsWithStaleStatistics"); + if (staleStatsEl != null) + { + var cols = staleStatsEl.Elements(Ns + "ColumnReference") + .Select(c => c.Attribute("Column")?.Value ?? "") + .Where(s => !string.IsNullOrEmpty(s)); + result.Add(new PlanWarning + { + WarningType = "Stale Statistics", + Message = $"Stale statistics on: {string.Join(", ", cols)}", + Severity = PlanWarningSeverity.Warning + }); + } + + // Wait warnings + foreach (var waitEl in warningsEl.Elements(Ns + "Wait")) + { + result.Add(new PlanWarning + { + WarningType = "Wait", + Message = $"{waitEl.Attribute("WaitType")?.Value}: {waitEl.Attribute("WaitTime")?.Value}ms", + Severity = PlanWarningSeverity.Info + }); + } + + return result; + } +} diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index d56b15e..d030edb 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -7,7 +7,7 @@ namespace PlanViewer.Core.Services; -public static class ShowPlanParser +public static partial class ShowPlanParser { private static readonly XNamespace Ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; @@ -624,1221 +624,5 @@ private static void ParseQueryPlanElements(PlanStatement stmt, XElement stmtEl, } } - private static PlanNode ParseRelOp(XElement relOpEl) - { - var node = new PlanNode - { - NodeId = (int)ParseDouble(relOpEl.Attribute("NodeId")?.Value), - PhysicalOp = relOpEl.Attribute("PhysicalOp")?.Value ?? "", - LogicalOp = relOpEl.Attribute("LogicalOp")?.Value ?? "", - EstimatedTotalSubtreeCost = ParseDouble(relOpEl.Attribute("EstimatedTotalSubtreeCost")?.Value), - EstimateRows = ParseDouble(relOpEl.Attribute("EstimateRows")?.Value), - EstimateIO = ParseDouble(relOpEl.Attribute("EstimateIO")?.Value), - EstimateCPU = ParseDouble(relOpEl.Attribute("EstimateCPU")?.Value), - EstimateRebinds = ParseDouble(relOpEl.Attribute("EstimateRebinds")?.Value), - EstimateRewinds = ParseDouble(relOpEl.Attribute("EstimateRewinds")?.Value), - EstimatedRowSize = (int)ParseDouble(relOpEl.Attribute("AvgRowSize")?.Value), - Parallel = relOpEl.Attribute("Parallel")?.Value is "true" or "1", - Partitioned = relOpEl.Attribute("Partitioned")?.Value is "true" or "1", - ExecutionMode = relOpEl.Attribute("EstimatedExecutionMode")?.Value, - IsAdaptive = relOpEl.Attribute("IsAdaptive")?.Value is "true" or "1", - AdaptiveThresholdRows = ParseDouble(relOpEl.Attribute("AdaptiveThresholdRows")?.Value), - EstimatedJoinType = relOpEl.Attribute("EstimatedJoinType")?.Value, - // Wave 3.14: Estimated DOP per operator - EstimatedDOP = (int)ParseDouble(relOpEl.Attribute("EstimatedAvailableDegreeOfParallelism")?.Value), - // XSD gap: RelOp-level metadata - GroupExecuted = relOpEl.Attribute("GroupExecuted")?.Value is "true" or "1", - RemoteDataAccess = relOpEl.Attribute("RemoteDataAccess")?.Value is "true" or "1", - OptimizedHalloweenProtectionUsed = relOpEl.Attribute("OptimizedHalloweenProtectionUsed")?.Value is "true" or "1", - StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value) - }; - - // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp - // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool" - if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Eager " + node.PhysicalOp; - } - else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase) - && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase)) - { - node.PhysicalOp = "Lazy " + node.PhysicalOp; - } - - - // Icon mapping is deferred until after StorageType is parsed below, - // so columnstore scans (which surface as Clustered/Index Scan with - // Storage="ColumnStore") can be routed to the columnstore icon. - - // Handle operator-specific element - var physicalOpEl = GetOperatorElement(relOpEl); - if (physicalOpEl != null) - { - // Top N Sort — XML element is but PhysicalOp is "Sort" - if (physicalOpEl.Name.LocalName == "TopSort") - node.LogicalOp = "Top N Sort"; - - // Object reference (table/index name) — scoped to stop at child RelOps - var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault(); - if (objEl != null) - { - var db = objEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); - var schema = objEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - var table = CleanTempTableName(objEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "") ?? ""); - var index = objEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - - node.DatabaseName = db; - node.IndexName = index; - - var shortParts = new List(); - if (!string.IsNullOrEmpty(schema)) shortParts.Add(schema); - if (!string.IsNullOrEmpty(table)) shortParts.Add(table); - node.ObjectName = shortParts.Count > 0 ? string.Join(".", shortParts) : null; - - var fullParts = new List(); - if (!string.IsNullOrEmpty(db)) fullParts.Add(db); - if (!string.IsNullOrEmpty(schema)) fullParts.Add(schema); - if (!string.IsNullOrEmpty(table)) fullParts.Add(table); - var fullName = string.Join(".", fullParts); - if (!string.IsNullOrEmpty(index)) - fullName += $".{index}"; - node.FullObjectName = !string.IsNullOrEmpty(fullName) ? fullName : null; - - node.StorageType = objEl.Attribute("Storage")?.Value; - node.ServerName = objEl.Attribute("Server")?.Value?.Replace("[", "").Replace("]", ""); - node.ObjectAlias = objEl.Attribute("Alias")?.Value?.Replace("[", "").Replace("]", ""); - node.IndexKind = objEl.Attribute("IndexKind")?.Value; - node.FilteredIndex = objEl.Attribute("Filtered")?.Value is "true" or "1"; - node.TableReferenceId = (int)ParseDouble(objEl.Attribute("TableReferenceId")?.Value); - } - - // Nonclustered indexes maintained by modification operators (Update/SimpleUpdate) - var opName = physicalOpEl.Name.LocalName; - if (opName is "Update" or "SimpleUpdate" or "CreateIndex") - { - var ncObjects = ScopedDescendants(physicalOpEl, Ns + "Object") - .Where(o => string.Equals(o.Attribute("IndexKind")?.Value, "NonClustered", StringComparison.OrdinalIgnoreCase)) - .ToList(); - node.NonClusteredIndexCount = ncObjects.Count; - foreach (var ncObj in ncObjects) - { - var ixName = ncObj.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - if (!string.IsNullOrEmpty(ixName)) - node.NonClusteredIndexNames.Add(ixName); - } - } - - // Hash keys for hash match operators - var hashKeysProbeEl = physicalOpEl.Element(Ns + "HashKeysProbe"); - if (hashKeysProbeEl != null) - { - var cols = hashKeysProbeEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - node.HashKeysProbe = string.Join(", ", cols); - } - var hashKeysBuildEl = physicalOpEl.Element(Ns + "HashKeysBuild"); - if (hashKeysBuildEl != null) - { - var cols = hashKeysBuildEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - node.HashKeysBuild = string.Join(", ", cols); - } - - // Ordered attribute - node.Ordered = physicalOpEl.Attribute("Ordered")?.Value == "true" || physicalOpEl.Attribute("Ordered")?.Value == "1"; - - // Seek predicates — scoped to stop at child RelOps - var seekPreds = ScopedDescendants(physicalOpEl, Ns + "SeekPredicateNew") - .Concat(ScopedDescendants(physicalOpEl, Ns + "SeekPredicate")); - var seekParts = new List(); - foreach (var sp in seekPreds) - { - foreach (var seekKeys in sp.Elements(Ns + "SeekKeys")) - { - // Each SeekKeys has Prefix, StartRange, EndRange with ScanType - foreach (var range in seekKeys.Elements()) - { - var scanType = range.Attribute("ScanType")?.Value; - var cols = range.Element(Ns + "RangeColumns")? - .Elements(Ns + "ColumnReference") - .Select(FormatColumnRef) - .ToList(); - var exprs = range.Element(Ns + "RangeExpressions")? - .Elements(Ns + "ScalarOperator") - .Select(so => so.Attribute("ScalarString")?.Value ?? "?") - .ToList(); - - if (cols != null && exprs != null) - { - var op = scanType switch - { - "EQ" => "=", "GT" => ">", "GE" => ">=", - "LT" => "<", "LE" => "<=", _ => scanType ?? "=" - }; - for (int ci = 0; ci < cols.Count && ci < exprs.Count; ci++) - seekParts.Add($"{cols[ci]} {op} {exprs[ci]}"); - } - } - } - } - if (seekParts.Count > 0) - node.SeekPredicates = string.Join(", ", seekParts); - - // GuessedSelectivity — check if optimizer guessed selectivity on predicates - if (ScopedDescendants(physicalOpEl, Ns + "GuessedSelectivity").Any()) - node.GuessedSelectivity = true; - - // Residual predicate - var predEl = physicalOpEl.Elements(Ns + "Predicate").FirstOrDefault(); - if (predEl != null) - { - var scalarOp = predEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.Predicate = scalarOp?.Attribute("ScalarString")?.Value; - } - - // Partitioning type (for parallelism operators) - node.PartitioningType = physicalOpEl.Attribute("PartitioningType")?.Value; - - // Build/Probe residuals (Hash Match) - var buildResEl = physicalOpEl.Element(Ns + "BuildResidual"); - if (buildResEl != null) - { - var so = buildResEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.BuildResidual = so?.Attribute("ScalarString")?.Value; - } - var probeResEl = physicalOpEl.Element(Ns + "ProbeResidual"); - if (probeResEl != null) - { - var so = probeResEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.ProbeResidual = so?.Attribute("ScalarString")?.Value; - } - - // Wave 2.1/2.2: Merge Residual + PassThru (Merge Join + Nested Loops) - var residualEl = physicalOpEl.Element(Ns + "Residual"); - if (residualEl != null) - { - var so = residualEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.MergeResidual = so?.Attribute("ScalarString")?.Value; - } - var passThruEl = physicalOpEl.Element(Ns + "PassThru"); - if (passThruEl != null) - { - var so = passThruEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.PassThru = so?.Attribute("ScalarString")?.Value; - } - - // OrderBy columns (Sort operator) - var orderByEl = physicalOpEl.Element(Ns + "OrderBy"); - if (orderByEl != null) - { - var obParts = orderByEl.Elements(Ns + "OrderByColumn") - .Select(obc => - { - var ascending = obc.Attribute("Ascending")?.Value != "false"; - var colRef = obc.Element(Ns + "ColumnReference"); - var name = colRef != null ? FormatColumnRef(colRef) : ""; - return string.IsNullOrEmpty(name) ? "" : $"{name} {(ascending ? "ASC" : "DESC")}"; - }) - .Where(s => !string.IsNullOrEmpty(s)); - var obStr = string.Join(", ", obParts); - if (!string.IsNullOrEmpty(obStr)) - node.OrderBy = obStr; - } - - // OuterReferences (Nested Loops) - var outerRefsEl = physicalOpEl.Element(Ns + "OuterReferences"); - if (outerRefsEl != null) - { - var refs = outerRefsEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - var refsStr = string.Join(", ", refs); - if (!string.IsNullOrEmpty(refsStr)) - node.OuterReferences = refsStr; - } - - // Inner/Outer side join columns (Merge Join) - node.InnerSideJoinColumns = ParseColumnList(physicalOpEl, "InnerSideJoinColumns"); - node.OuterSideJoinColumns = ParseColumnList(physicalOpEl, "OuterSideJoinColumns"); - - // GroupBy columns (Hash/Stream Aggregate) - node.GroupBy = ParseColumnList(physicalOpEl, "GroupBy"); - - // Partition columns (Parallelism) - node.PartitionColumns = ParseColumnList(physicalOpEl, "PartitionColumns"); - - // Wave 2.6: Parallelism HashKeys - node.HashKeys = ParseColumnList(physicalOpEl, "HashKeys"); - - // Segment column - var segColEl = physicalOpEl.Element(Ns + "SegmentColumn")?.Element(Ns + "ColumnReference"); - if (segColEl != null) - node.SegmentColumn = FormatColumnRef(segColEl); - - // Defined values (Compute Scalar) - var definedValsEl = physicalOpEl.Element(Ns + "DefinedValues"); - if (definedValsEl != null) - { - var dvParts = new List(); - foreach (var dvEl in definedValsEl.Elements(Ns + "DefinedValue")) - { - var colRef = dvEl.Element(Ns + "ColumnReference"); - var scalarOp = dvEl.Element(Ns + "ScalarOperator"); - var colName = colRef != null ? FormatColumnRef(colRef) : ""; - var expr = scalarOp?.Attribute("ScalarString")?.Value ?? ""; - if (!string.IsNullOrEmpty(colName) && !string.IsNullOrEmpty(expr)) - dvParts.Add($"{colName} = {expr}"); - else if (!string.IsNullOrEmpty(expr)) - dvParts.Add(expr); - else if (!string.IsNullOrEmpty(colName)) - dvParts.Add(colName); - } - if (dvParts.Count > 0) - node.DefinedValues = string.Join("; ", dvParts); - } - - // IndexScan / TableScan properties - node.ScanDirection = physicalOpEl.Attribute("ScanDirection")?.Value; - node.ForcedIndex = physicalOpEl.Attribute("ForcedIndex")?.Value is "true" or "1"; - node.ForceScan = physicalOpEl.Attribute("ForceScan")?.Value is "true" or "1"; - node.ForceSeek = physicalOpEl.Attribute("ForceSeek")?.Value is "true" or "1"; - node.NoExpandHint = physicalOpEl.Attribute("NoExpandHint")?.Value is "true" or "1"; - node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1"; - node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1"; - - // Override PhysicalOp, LogicalOp, and icon when Lookup=true. - // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with - // rather than "Key Lookup (Clustered)" — correct the label here so all display - // paths (node card, tooltip, properties panel) show the right operator name. - if (node.Lookup) - { - var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true - || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); - node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; - node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; - node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; - } - - // Table cardinality and rows to be read (on per XSD) - node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value); - node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value); - node.EstimateRowsWithoutRowGoal = ParseDouble(relOpEl.Attribute("EstimateRowsWithoutRowGoal")?.Value); - if (node.EstimatedRowsRead == 0) - node.EstimatedRowsRead = node.EstimateRowsWithoutRowGoal; - - // TOP operator properties - var topExprEl = physicalOpEl.Element(Ns + "TopExpression")?.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - if (topExprEl != null) - node.TopExpression = topExprEl.Attribute("ScalarString")?.Value; - node.IsPercent = physicalOpEl.Attribute("IsPercent")?.Value is "true" or "1"; - node.WithTies = physicalOpEl.Attribute("WithTies")?.Value is "true" or "1"; - - // Wave 2.7: Top OffsetExpression, RowCount, Rows - var offsetEl = physicalOpEl.Element(Ns + "OffsetExpression")?.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - if (offsetEl != null) - node.OffsetExpression = offsetEl.Attribute("ScalarString")?.Value; - node.RowCount = physicalOpEl.Attribute("RowCount")?.Value is "true" or "1"; - node.TopRows = (int)ParseDouble(physicalOpEl.Attribute("Rows")?.Value); - - // Sort properties - node.SortDistinct = physicalOpEl.Attribute("Distinct")?.Value is "true" or "1"; - - // Filter properties - node.StartupExpression = physicalOpEl.Attribute("StartupExpression")?.Value is "true" or "1"; - - // Nested Loops properties - node.NLOptimized = physicalOpEl.Attribute("Optimized")?.Value is "true" or "1"; - node.WithOrderedPrefetch = physicalOpEl.Attribute("WithOrderedPrefetch")?.Value is "true" or "1"; - node.WithUnorderedPrefetch = physicalOpEl.Attribute("WithUnorderedPrefetch")?.Value is "true" or "1"; - - // Hash Match properties - node.ManyToMany = physicalOpEl.Attribute("ManyToMany")?.Value is "true" or "1"; - node.BitmapCreator = physicalOpEl.Attribute("BitmapCreator")?.Value is "true" or "1"; - - // Parallelism properties - node.Remoting = physicalOpEl.Attribute("Remoting")?.Value is "true" or "1"; - node.LocalParallelism = physicalOpEl.Attribute("LocalParallelism")?.Value is "true" or "1"; - - // Wave 3.8: Spool Stack + PrimaryNodeId - node.SpoolStack = physicalOpEl.Attribute("Stack")?.Value is "true" or "1"; - node.PrimaryNodeId = (int)ParseDouble(physicalOpEl.Attribute("PrimaryNodeId")?.Value); - - // Eager Index Spool — suggest CREATE INDEX from SeekPredicateNew + OutputList - if (node.LogicalOp == "Eager Spool") - { - var spoolSeek = physicalOpEl.Element(Ns + "SeekPredicateNew") - ?? physicalOpEl.Element(Ns + "SeekPredicate"); - if (spoolSeek != null) - { - var rangeCols = spoolSeek.Descendants(Ns + "RangeColumns") - .SelectMany(rc => rc.Elements(Ns + "ColumnReference")); - - var keyColumns = new List(); - string? tblSchema = null; - string? tblName = null; - - foreach (var col in rangeCols) - { - var colName = col.Attribute("Column")?.Value; - if (!string.IsNullOrEmpty(colName)) - keyColumns.Add(colName); - tblSchema ??= col.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - tblName ??= col.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); - } - - if (keyColumns.Count > 0 && !string.IsNullOrEmpty(tblName)) - { - var includeCols = relOpEl.Element(Ns + "OutputList")?.Elements(Ns + "ColumnReference") - .Select(c => c.Attribute("Column")?.Value) - .Where(c => !string.IsNullOrEmpty(c) && !keyColumns.Contains(c)) - .ToList() ?? new List(); - - var prefix = !string.IsNullOrEmpty(tblSchema) ? $"{tblSchema}.{tblName}" : tblName; - var keyStr = string.Join(", ", keyColumns); - var sql = $"CREATE INDEX [{string.Join("_", keyColumns)}] ON {prefix} ({keyStr})"; - if (includeCols.Count > 0) - sql += $" INCLUDE ({string.Join(", ", includeCols)})"; - sql += ";"; - node.SuggestedIndex = sql; - } - } - } - - // Wave 3.9: Update DMLRequestSort + ActionColumn - node.DMLRequestSort = physicalOpEl.Attribute("DMLRequestSort")?.Value is "true" or "1"; - var actionColEl = physicalOpEl.Element(Ns + "ActionColumn")?.Element(Ns + "ColumnReference"); - if (actionColEl != null) - node.ActionColumn = FormatColumnRef(actionColEl); - - // SET predicate (UPDATE operator) - var setPredicateEl = physicalOpEl.Element(Ns + "SetPredicate"); - if (setPredicateEl != null) - { - var so = setPredicateEl.Descendants(Ns + "ScalarOperator").FirstOrDefault(); - node.SetPredicate = so?.Attribute("ScalarString")?.Value; - } - - // ActualJoinType from runtime info on adaptive joins - node.ActualJoinType = physicalOpEl.Attribute("ActualJoinType")?.Value; - - // XSD gap: ForceSeekColumnCount (IndexScan) - node.ForceSeekColumnCount = (int)ParseDouble(physicalOpEl.Attribute("ForceSeekColumnCount")?.Value); - - // XSD gap: PartitionId (IndexScan, TableScan, Sort, NestedLoops, AdaptiveJoin) - var partitionIdEl = physicalOpEl.Element(Ns + "PartitionId"); - if (partitionIdEl != null) - { - var pidCols = partitionIdEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - var pidStr = string.Join(", ", pidCols); - if (!string.IsNullOrEmpty(pidStr)) - node.PartitionId = pidStr; - } - - // XSD gap: StarJoinInfo (Hash, Merge, NL, AdaptiveJoin) - var starJoinEl = physicalOpEl.Element(Ns + "StarJoinInfo"); - if (starJoinEl != null) - { - node.IsStarJoin = starJoinEl.Attribute("Root")?.Value is "true" or "1"; - node.StarJoinOperationType = starJoinEl.Attribute("OperationType")?.Value; - } - - // XSD gap: ProbeColumn (NL, Parallelism, Update) - var probeColEl = physicalOpEl.Element(Ns + "ProbeColumn")?.Element(Ns + "ColumnReference"); - if (probeColEl != null) - node.ProbeColumn = FormatColumnRef(probeColEl); - - // XSD gap: InRow (Parallelism) - node.InRow = physicalOpEl.Attribute("InRow")?.Value is "true" or "1"; - - // XSD gap: ComputeSequence (ComputeScalar) - node.ComputeSequence = physicalOpEl.Attribute("ComputeSequence")?.Value is "true" or "1"; - - // XSD gap: RollupInfo (StreamAggregate) - var rollupEl = physicalOpEl.Element(Ns + "RollupInfo"); - if (rollupEl != null) - { - node.RollupHighestLevel = (int)ParseDouble(rollupEl.Attribute("HighestLevel")?.Value); - foreach (var rlEl in rollupEl.Elements(Ns + "RollupLevel")) - node.RollupLevels.Add((int)ParseDouble(rlEl.Attribute("Level")?.Value)); - } - - // XSD gap: TVF ParameterList - var tvfParamListEl = physicalOpEl.Element(Ns + "ParameterList"); - if (tvfParamListEl != null) - { - var tvfCols = tvfParamListEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - var tvfStr = string.Join(", ", tvfCols); - if (!string.IsNullOrEmpty(tvfStr)) - node.TvfParameters = tvfStr; - // Also check for ScalarOperator children (TVF can have scalar params) - if (string.IsNullOrEmpty(node.TvfParameters)) - { - var tvfScalars = tvfParamListEl.Elements(Ns + "ScalarOperator") - .Select(s => s.Attribute("ScalarString")?.Value) - .Where(s => !string.IsNullOrEmpty(s)); - var tvfScalarStr = string.Join(", ", tvfScalars); - if (!string.IsNullOrEmpty(tvfScalarStr)) - node.TvfParameters = tvfScalarStr; - } - } - - // XSD gap: OriginalActionColumn (Update) - var origActionColEl = physicalOpEl.Element(Ns + "OriginalActionColumn")?.Element(Ns + "ColumnReference"); - if (origActionColEl != null) - node.OriginalActionColumn = FormatColumnRef(origActionColEl); - - // XSD gap: Scalar UDF structured detection - foreach (var udfEl in ScopedDescendants(physicalOpEl, Ns + "UserDefinedFunction")) - { - var udfRef = new ScalarUdfReference - { - FunctionName = udfEl.Attribute("FunctionName")?.Value?.Replace("[", "").Replace("]", "") ?? "", - IsClrFunction = udfEl.Attribute("IsClrFunction")?.Value is "true" or "1" - }; - var clrEl = udfEl.Element(Ns + "CLRFunction"); - if (clrEl != null) - { - udfRef.ClrAssembly = clrEl.Attribute("Assembly")?.Value; - udfRef.ClrClass = clrEl.Attribute("Class")?.Value; - udfRef.ClrMethod = clrEl.Attribute("Method")?.Value; - } - if (!string.IsNullOrEmpty(udfRef.FunctionName)) - node.ScalarUdfs.Add(udfRef); - } - - // XSD gap: TieColumns (Top operator) - node.TieColumns = ParseColumnList(physicalOpEl, "TieColumns"); - - // XSD gap: UDXName (Extension operator) - node.UdxName = physicalOpEl.Attribute("UDXName")?.Value; - - // XSD gap: Operator-level IndexedViewInfo - var opIvInfoEl = physicalOpEl.Element(Ns + "IndexedViewInfo"); - if (opIvInfoEl != null) - { - foreach (var ivObjEl in opIvInfoEl.Elements(Ns + "Object")) - { - var ivDb = ivObjEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); - var ivSchema = ivObjEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - var ivTable = ivObjEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); - var ivIndex = ivObjEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - var ivParts = new List(); - if (!string.IsNullOrEmpty(ivDb)) ivParts.Add(ivDb); - if (!string.IsNullOrEmpty(ivSchema)) ivParts.Add(ivSchema); - if (!string.IsNullOrEmpty(ivTable)) ivParts.Add(ivTable); - if (!string.IsNullOrEmpty(ivIndex)) ivParts.Add(ivIndex); - var ivName = string.Join(".", ivParts); - if (!string.IsNullOrEmpty(ivName)) - node.OperatorIndexedViews.Add(ivName); - } - } - - // XSD gap: NamedParameterList (IndexScan) - var namedParamListEl = physicalOpEl.Element(Ns + "NamedParameterList"); - if (namedParamListEl != null) - { - foreach (var npEl in namedParamListEl.Elements(Ns + "NamedParameter")) - { - var np = new NamedParameterInfo - { - Name = npEl.Attribute("Name")?.Value ?? "" - }; - var npScalar = npEl.Element(Ns + "ScalarOperator"); - if (npScalar != null) - np.ScalarString = npScalar.Attribute("ScalarString")?.Value; - if (!string.IsNullOrEmpty(np.Name)) - node.NamedParameters.Add(np); - } - } - - // XSD gap: Remote operator metadata - node.RemoteDestination = physicalOpEl.Attribute("RemoteDestination")?.Value; - node.RemoteSource = physicalOpEl.Attribute("RemoteSource")?.Value; - node.RemoteObject = physicalOpEl.Attribute("RemoteObject")?.Value; - node.RemoteQuery = physicalOpEl.Attribute("RemoteQuery")?.Value; - - // ForeignKeyReferenceCheck attributes - node.ForeignKeyReferencesCount = (int)ParseDouble(physicalOpEl.Attribute("ForeignKeyReferencesCount")?.Value); - node.NoMatchingIndexCount = (int)ParseDouble(physicalOpEl.Attribute("NoMatchingIndexCount")?.Value); - node.PartialMatchingIndexCount = (int)ParseDouble(physicalOpEl.Attribute("PartialMatchingIndexCount")?.Value); - - // ConstantScan Values — parse Values/Row/ScalarOperator children - var valuesEl = physicalOpEl.Element(Ns + "Values"); - if (valuesEl != null) - { - var rowParts = new List(); - foreach (var rowEl in valuesEl.Elements(Ns + "Row")) - { - var scalars = rowEl.Elements(Ns + "ScalarOperator") - .Select(s => s.Attribute("ScalarString")?.Value ?? "") - .Where(s => !string.IsNullOrEmpty(s)); - var rowStr = string.Join(", ", scalars); - if (!string.IsNullOrEmpty(rowStr)) - rowParts.Add($"({rowStr})"); - } - if (rowParts.Count > 0) - node.ConstantScanValues = string.Join(", ", rowParts); - } - - // UDX UsedUDXColumns — column references for CLR aggregate operators - var udxColsEl = physicalOpEl.Element(Ns + "UsedUDXColumns"); - if (udxColsEl != null) - { - var udxCols = udxColsEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - var udxColStr = string.Join(", ", udxCols); - if (!string.IsNullOrEmpty(udxColStr)) - node.UdxUsedColumns = udxColStr; - } - } - - // Output columns - var outputList = relOpEl.Element(Ns + "OutputList"); - if (outputList != null) - { - var cols = outputList.Elements(Ns + "ColumnReference") - .Select(c => - { - var col = c.Attribute("Column")?.Value ?? ""; - var tbl = c.Attribute("Table")?.Value ?? ""; - return string.IsNullOrEmpty(tbl) ? col : $"{tbl}.{col}"; - }) - .Where(s => !string.IsNullOrEmpty(s)); - var colList = string.Join(", ", cols); - if (!string.IsNullOrEmpty(colList)) - node.OutputColumns = colList.Replace("[", "").Replace("]", ""); - } - - // Warnings - node.Warnings = ParseWarnings(relOpEl); - - // SpillOccurred detail flag (node-level boolean) - var warningsCheckEl = relOpEl.Element(Ns + "Warnings"); - if (warningsCheckEl?.Element(Ns + "SpillOccurred") != null) - node.SpillOccurredDetail = true; - - // Wave 3.2: MemoryFractions (on RelOp) - var memFracEl = relOpEl.Element(Ns + "MemoryFractions"); - if (memFracEl != null) - { - node.MemoryFractionInput = ParseDouble(memFracEl.Attribute("Input")?.Value); - node.MemoryFractionOutput = ParseDouble(memFracEl.Attribute("Output")?.Value); - } - - // Wave 3.3: RunTimePartitionSummary (on RelOp) - var rtPartEl = relOpEl.Element(Ns + "RunTimePartitionSummary"); - if (rtPartEl != null) - { - var partAccEl = rtPartEl.Element(Ns + "PartitionsAccessed"); - if (partAccEl != null) - { - node.PartitionsAccessed = (int)ParseDouble(partAccEl.Attribute("PartitionCount")?.Value); - var ranges = partAccEl.Elements(Ns + "PartitionRange") - .Select(r => $"{r.Attribute("Start")?.Value}-{r.Attribute("End")?.Value}"); - node.PartitionRanges = string.Join(", ", ranges); - if (string.IsNullOrEmpty(node.PartitionRanges)) - node.PartitionRanges = null; - } - } - - // Wave 2.4: Per-operator memory grants (MemoryGrant on RelOp) - var memGrantEl = relOpEl.Element(Ns + "MemoryGrant"); - if (memGrantEl != null) - { - node.MemoryGrantKB = ParseLong(memGrantEl.Attribute("GrantedMemory")?.Value); - node.DesiredMemoryKB = ParseLong(memGrantEl.Attribute("DesiredMemory")?.Value); - node.MaxUsedMemoryKB = ParseLong(memGrantEl.Attribute("MaxUsedMemory")?.Value); - } - - // Runtime information (actual plan) - var runtimeEl = relOpEl.Element(Ns + "RunTimeInformation"); - if (runtimeEl != null) - { - node.HasActualStats = true; - long totalRows = 0, totalExecutions = 0, totalRowsRead = 0; - long totalRebinds = 0, totalRewinds = 0; - long maxElapsed = 0, totalCpu = 0; - long totalLogicalReads = 0, totalPhysicalReads = 0; - long totalScans = 0, totalReadAheads = 0; - long totalLobLogicalReads = 0, totalLobPhysicalReads = 0, totalLobReadAheads = 0; - long totalSegmentReads = 0, totalSegmentSkips = 0; - long totalUdfCpu = 0, maxUdfElapsed = 0; - long maxInputMemoryGrant = 0, maxOutputMemoryGrant = 0, maxUsedMemoryGrant = 0; - string? actualExecMode = null; - - foreach (var thread in runtimeEl.Elements(Ns + "RunTimeCountersPerThread")) - { - totalRows += ParseLong(thread.Attribute("ActualRows")?.Value); - totalExecutions += ParseLong(thread.Attribute("ActualExecutions")?.Value); - totalRowsRead += ParseLong(thread.Attribute("ActualRowsRead")?.Value); - totalRebinds += ParseLong(thread.Attribute("ActualRebinds")?.Value); - totalRewinds += ParseLong(thread.Attribute("ActualRewinds")?.Value); - totalCpu += ParseLong(thread.Attribute("ActualCPUms")?.Value); - totalLogicalReads += ParseLong(thread.Attribute("ActualLogicalReads")?.Value); - totalPhysicalReads += ParseLong(thread.Attribute("ActualPhysicalReads")?.Value); - totalScans += ParseLong(thread.Attribute("ActualScans")?.Value); - totalReadAheads += ParseLong(thread.Attribute("ActualReadAheads")?.Value); - totalLobLogicalReads += ParseLong(thread.Attribute("ActualLobLogicalReads")?.Value); - totalLobPhysicalReads += ParseLong(thread.Attribute("ActualLobPhysicalReads")?.Value); - totalLobReadAheads += ParseLong(thread.Attribute("ActualLobReadAheads")?.Value); - - // Wave 3.10: Columnstore segment reads/skips - totalSegmentReads += ParseLong(thread.Attribute("ActualSegmentReads")?.Value); - totalSegmentSkips += ParseLong(thread.Attribute("ActualSegmentSkips")?.Value); - - // Wave 3.11: UDF timing - totalUdfCpu += ParseLong(thread.Attribute("UdfCpuTime")?.Value); - var udfElapsed = ParseLong(thread.Attribute("UdfElapsedTime")?.Value); - if (udfElapsed > maxUdfElapsed) maxUdfElapsed = udfElapsed; - // Per-operator memory grant (same value on all threads, take max) - var inputMem = ParseLong(thread.Attribute("InputMemoryGrant")?.Value); - var outputMem = ParseLong(thread.Attribute("OutputMemoryGrant")?.Value); - var usedMem = ParseLong(thread.Attribute("UsedMemoryGrant")?.Value); - if (inputMem > maxInputMemoryGrant) maxInputMemoryGrant = inputMem; - if (outputMem > maxOutputMemoryGrant) maxOutputMemoryGrant = outputMem; - if (usedMem > maxUsedMemoryGrant) maxUsedMemoryGrant = usedMem; - - actualExecMode ??= thread.Attribute("ActualExecutionMode")?.Value; - - var elapsed = ParseLong(thread.Attribute("ActualElapsedms")?.Value); - if (elapsed > maxElapsed) maxElapsed = elapsed; - } - - node.ActualRows = totalRows; - node.ActualExecutions = totalExecutions; - node.ActualRowsRead = totalRowsRead; - node.ActualRebinds = totalRebinds; - node.ActualRewinds = totalRewinds; - node.ActualElapsedMs = maxElapsed; - node.ActualCPUMs = totalCpu; - node.ActualLogicalReads = totalLogicalReads; - node.ActualPhysicalReads = totalPhysicalReads; - node.ActualScans = totalScans; - node.ActualReadAheads = totalReadAheads; - node.ActualLobLogicalReads = totalLobLogicalReads; - node.ActualLobPhysicalReads = totalLobPhysicalReads; - node.ActualLobReadAheads = totalLobReadAheads; - node.ActualExecutionMode = actualExecMode; - node.ActualSegmentReads = totalSegmentReads; - node.ActualSegmentSkips = totalSegmentSkips; - node.UdfCpuTimeMs = totalUdfCpu; - node.UdfElapsedTimeMs = maxUdfElapsed; - node.InputMemoryGrantKB = maxInputMemoryGrant; - node.OutputMemoryGrantKB = maxOutputMemoryGrant; - node.UsedMemoryGrantKB = maxUsedMemoryGrant; - - // Store per-thread data for parallel skew analysis - foreach (var thread in runtimeEl.Elements(Ns + "RunTimeCountersPerThread")) - { - node.PerThreadStats.Add(new PerThreadRuntimeInfo - { - ThreadId = (int)ParseDouble(thread.Attribute("Thread")?.Value), - ActualRows = ParseLong(thread.Attribute("ActualRows")?.Value), - ActualExecutions = ParseLong(thread.Attribute("ActualExecutions")?.Value), - ActualElapsedMs = ParseLong(thread.Attribute("ActualElapsedms")?.Value), - ActualCPUMs = ParseLong(thread.Attribute("ActualCPUms")?.Value), - ActualRowsRead = ParseLong(thread.Attribute("ActualRowsRead")?.Value), - ActualLogicalReads = ParseLong(thread.Attribute("ActualLogicalReads")?.Value), - ActualPhysicalReads = ParseLong(thread.Attribute("ActualPhysicalReads")?.Value), - ActualScans = ParseLong(thread.Attribute("ActualScans")?.Value), - ActualReadAheads = ParseLong(thread.Attribute("ActualReadAheads")?.Value), - FirstActiveTime = ParseLong(thread.Attribute("FirstActiveTime")?.Value), - LastActiveTime = ParseLong(thread.Attribute("LastActiveTime")?.Value), - OpenTime = ParseLong(thread.Attribute("OpenTime")?.Value), - FirstRowTime = ParseLong(thread.Attribute("FirstRowTime")?.Value), - LastRowTime = ParseLong(thread.Attribute("LastRowTime")?.Value), - CloseTime = ParseLong(thread.Attribute("CloseTime")?.Value), - InputMemoryGrant = ParseLong(thread.Attribute("InputMemoryGrant")?.Value), - OutputMemoryGrant = ParseLong(thread.Attribute("OutputMemoryGrant")?.Value), - UsedMemoryGrant = ParseLong(thread.Attribute("UsedMemoryGrant")?.Value), - Batches = ParseLong(thread.Attribute("Batches")?.Value), - ActualEndOfScans = ParseLong(thread.Attribute("ActualEndOfScans")?.Value), - ActualLocallyAggregatedRows = ParseLong(thread.Attribute("ActualLocallyAggregatedRows")?.Value), - IsInterleavedExecuted = thread.Attribute("IsInterleavedExecuted")?.Value is "true" or "1", - RowRequalifications = ParseLong(thread.Attribute("RowRequalifications")?.Value) - }); - } - } - - // Map to icon — done here so columnstore scans (Clustered/Index Scan - // with Storage="ColumnStore") and Parallelism subtypes (which depend on - // LogicalOp) can be routed to their specific icons. - node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp, node.StorageType, node.LogicalOp); - - // Recurse into child RelOps - foreach (var childRelOp in FindChildRelOps(relOpEl)) - { - var childNode = ParseRelOp(childRelOp); - childNode.Parent = node; - node.Children.Add(childNode); - } - - return node; - } - - private static XElement? GetOperatorElement(XElement relOpEl) - { - foreach (var child in relOpEl.Elements()) - { - var name = child.Name.LocalName; - if (name != "OutputList" && name != "RunTimeInformation" && name != "Warnings" - && name != "MemoryFractions" && name != "RunTimePartitionSummary" - && name != "MemoryGrant" && name != "InternalInfo") - { - return child; - } - } - return null; - } - - private static IEnumerable FindChildRelOps(XElement relOpEl) - { - var operatorEl = GetOperatorElement(relOpEl); - if (operatorEl == null) yield break; - - foreach (var child in operatorEl.Elements(Ns + "RelOp")) - yield return child; - - foreach (var child in operatorEl.Elements()) - { - if (child.Name.LocalName == "RelOp") continue; - foreach (var nestedRelOp in child.Elements(Ns + "RelOp")) - yield return nestedRelOp; - } - } - - private static List ParseMissingIndexes(XElement queryPlanEl) - { - var result = new List(); - var missingIndexesEl = queryPlanEl.Element(Ns + "MissingIndexes"); - if (missingIndexesEl == null) return result; - - foreach (var groupEl in missingIndexesEl.Elements(Ns + "MissingIndexGroup")) - { - var impact = ParseDouble(groupEl.Attribute("Impact")?.Value); - foreach (var indexEl in groupEl.Elements(Ns + "MissingIndex")) - { - var mi = new MissingIndex - { - Database = indexEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", "") ?? "", - Schema = indexEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", "") ?? "", - Table = CleanTempTableName(indexEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "") ?? ""), - Impact = impact - }; - - foreach (var colGroup in indexEl.Elements(Ns + "ColumnGroup")) - { - var usage = colGroup.Attribute("Usage")?.Value ?? ""; - var cols = colGroup.Elements(Ns + "Column") - .Select(c => c.Attribute("Name")?.Value?.Replace("[", "").Replace("]", "") ?? "") - .Where(s => !string.IsNullOrEmpty(s)) - .ToList(); - - switch (usage) - { - case "EQUALITY": mi.EqualityColumns = cols; break; - case "INEQUALITY": mi.InequalityColumns = cols; break; - case "INCLUDE": mi.IncludeColumns = cols; break; - } - } - - var keyCols = mi.EqualityColumns.Concat(mi.InequalityColumns).ToList(); - if (keyCols.Count > 0) - { - var quotedKeyCols = keyCols.Select(c => $"[{c}]"); - var create = $"CREATE NONCLUSTERED INDEX [{mi.Table}_{string.Join("_", keyCols.Take(3))}]\nON [{mi.Schema}].[{mi.Table}] ({string.Join(", ", quotedKeyCols)})"; - if (mi.IncludeColumns.Count > 0) - { - var quotedIncludes = mi.IncludeColumns.Select(c => $"[{c}]"); - create += $"\nINCLUDE ({string.Join(", ", quotedIncludes)})"; - } - create += ";"; - mi.CreateStatement = create; - } - - result.Add(mi); - } - } - return result; - } - - /// - /// Strips the internal padding and hex session suffix from temp table names. - /// SQL Server internally pads #temp names with underscores to 116 chars, then appends a hex suffix. - /// e.g. "#comment_sil_vous_plait_______________________________0000000000A86" → "#comment_sil_vous_plait" - /// - private static string CleanTempTableName(string name) - { - if (name.Length == 0 || name[0] != '#') return name; - - // Find the end of the real name: trim trailing hex suffix, then trailing underscores - // The hex suffix is 8-16 hex chars at the end; the padding is consecutive underscores before it - var i = name.Length - 1; - - // Skip trailing hex digits (0-9, A-F, a-f) - while (i > 0 && IsHexDigit(name[i])) i--; - - // Skip trailing underscores (the padding) - while (i > 0 && name[i] == '_') i--; - - // Only clean if we actually removed a meaningful amount (at least 8 chars of padding+hex) - if (name.Length - i > 8) - return name[..(i + 1)]; - - return name; - } - - private static bool IsHexDigit(char c) => - (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); - - /// - /// Parse warnings from a parent element that contains a <Warnings> child (e.g. RelOp). - /// - private static List ParseWarnings(XElement parentEl) - { - var warningsEl = parentEl.Element(Ns + "Warnings"); - if (warningsEl == null) return new List(); - return ParseWarningsFromElement(warningsEl); - } - - /// - /// Parse warnings directly from a <Warnings> element. - /// - private static List ParseWarningsFromElement(XElement warningsEl) - { - var result = new List(); - - // No join predicate - if (warningsEl.Attribute("NoJoinPredicate")?.Value is "true" or "1") - { - result.Add(new PlanWarning - { - WarningType = "No Join Predicate", - Message = "This join triggered a no join predicate warning, which is worth checking on, but is often misleading. The optimizer may have removed a redundant predicate after simplification.", - Severity = PlanWarningSeverity.Warning - }); - } - - if (warningsEl.Attribute("SpatialGuess")?.Value is "true" or "1") - { - result.Add(new PlanWarning - { - WarningType = "Spatial Guess", - Message = "Spatial index selectivity was guessed", - Severity = PlanWarningSeverity.Info - }); - } - - if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") - { - // Parse child UnmatchedIndexes detail if present - var unmatchedMsg = "Indexes could not be matched due to parameterization"; - var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes"); - if (unmatchedEl != null) - { - var unmatchedDetails = new List(); - foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization")) - { - var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", ""); - var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", ""); - var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", ""); - var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); - var parts = new List(); - if (!string.IsNullOrEmpty(db)) parts.Add(db); - if (!string.IsNullOrEmpty(schema)) parts.Add(schema); - if (!string.IsNullOrEmpty(table)) parts.Add(table); - if (!string.IsNullOrEmpty(index)) parts.Add(index); - if (parts.Count > 0) - unmatchedDetails.Add(string.Join(".", parts)); - } - if (unmatchedDetails.Count > 0) - unmatchedMsg += ": " + string.Join(", ", unmatchedDetails); - } - result.Add(new PlanWarning - { - WarningType = "Unmatched Indexes", - Message = unmatchedMsg, - Severity = PlanWarningSeverity.Warning - }); - } - - if (warningsEl.Attribute("FullUpdateForOnlineIndexBuild")?.Value is "true" or "1") - { - result.Add(new PlanWarning - { - WarningType = "Full Update for Online Index Build", - Message = "Full update required for online index build operation", - Severity = PlanWarningSeverity.Info - }); - } - - // Spill to TempDb — collect SpillToTempDb level/thread info first - var spillLevel = ""; - var spillThreads = ""; - var spillToTempDbEl = warningsEl.Element(Ns + "SpillToTempDb"); - if (spillToTempDbEl != null) - { - spillLevel = spillToTempDbEl.Attribute("SpillLevel")?.Value ?? "?"; - spillThreads = spillToTempDbEl.Attribute("SpilledThreadCount")?.Value ?? "?"; - } - - // Sort spill details — merged with SpillToTempDb level/thread info - foreach (var sortSpillEl in warningsEl.Elements(Ns + "SortSpillDetails")) - { - var granted = ParseLong(sortSpillEl.Attribute("GrantedMemoryKb")?.Value); - var used = ParseLong(sortSpillEl.Attribute("UsedMemoryKb")?.Value); - var writes = ParseLong(sortSpillEl.Attribute("WritesToTempDb")?.Value); - var reads = ParseLong(sortSpillEl.Attribute("ReadsFromTempDb")?.Value); - var prefix = spillLevel != "" ? $"Sort spill level {spillLevel}, {spillThreads} thread(s)" : "Sort spill"; - result.Add(new PlanWarning - { - WarningType = "Sort Spill", - Message = $"{prefix} — Granted: {granted:N0} KB, Used: {used:N0} KB, Writes: {writes:N0}, Reads: {reads:N0}", - Severity = PlanWarningSeverity.Warning, - SpillDetails = new SpillDetail - { - SpillType = "Sort", - GrantedMemoryKB = granted, - UsedMemoryKB = used, - WritesToTempDb = writes, - ReadsFromTempDb = reads - } - }); - } - - // Hash spill details — merged with SpillToTempDb level/thread info - foreach (var hashSpillEl in warningsEl.Elements(Ns + "HashSpillDetails")) - { - var granted = ParseLong(hashSpillEl.Attribute("GrantedMemoryKb")?.Value); - var used = ParseLong(hashSpillEl.Attribute("UsedMemoryKb")?.Value); - var writes = ParseLong(hashSpillEl.Attribute("WritesToTempDb")?.Value); - var reads = ParseLong(hashSpillEl.Attribute("ReadsFromTempDb")?.Value); - var prefix = spillLevel != "" ? $"Hash spill level {spillLevel}, {spillThreads} thread(s)" : "Hash spill"; - result.Add(new PlanWarning - { - WarningType = "Hash Spill", - Message = $"{prefix} — Granted: {granted:N0} KB, Used: {used:N0} KB, Writes: {writes:N0}, Reads: {reads:N0}", - Severity = PlanWarningSeverity.Warning, - SpillDetails = new SpillDetail - { - SpillType = "Hash", - GrantedMemoryKB = granted, - UsedMemoryKB = used, - WritesToTempDb = writes, - ReadsFromTempDb = reads - } - }); - } - - // Standalone SpillToTempDb — only emit if no Sort/Hash detail element consumed it - if (spillToTempDbEl != null && - !warningsEl.Elements(Ns + "SortSpillDetails").Any() && - !warningsEl.Elements(Ns + "HashSpillDetails").Any()) - { - var msg = $"Spill level {spillLevel}, {spillThreads} thread(s)"; - var grantedKB = ParseLong(spillToTempDbEl.Attribute("GrantedMemoryKB")?.Value); - var usedKB = ParseLong(spillToTempDbEl.Attribute("UsedMemoryKB")?.Value); - var writes = ParseLong(spillToTempDbEl.Attribute("WritesToTempDb")?.Value); - var reads = ParseLong(spillToTempDbEl.Attribute("ReadsFromTempDb")?.Value); - if (grantedKB > 0 || writes > 0) - { - msg += $" — Granted: {grantedKB:N0} KB, Used: {usedKB:N0} KB"; - if (writes > 0) msg += $", Writes: {writes:N0}"; - if (reads > 0) msg += $", Reads: {reads:N0}"; - } - - result.Add(new PlanWarning - { - WarningType = "Spill to TempDb", - Message = msg, - Severity = PlanWarningSeverity.Warning - }); - } - - // Exchange spill details - foreach (var exchSpillEl in warningsEl.Elements(Ns + "ExchangeSpillDetails")) - { - result.Add(new PlanWarning - { - WarningType = "Exchange Spill", - Message = $"Exchange spill — {ParseLong(exchSpillEl.Attribute("WritesToTempDb")?.Value):N0} writes to TempDB. The parallel exchange operator ran out of memory buffers and spilled rows to disk. This typically means the memory grant was too small for the data volume flowing through this exchange.", - Severity = PlanWarningSeverity.Warning, - SpillDetails = new SpillDetail - { - SpillType = "Exchange", - WritesToTempDb = ParseLong(exchSpillEl.Attribute("WritesToTempDb")?.Value) - } - }); - } - - // SpillOccurred - var spillOccurredEl = warningsEl.Element(Ns + "SpillOccurred"); - if (spillOccurredEl != null) - { - result.Add(new PlanWarning - { - WarningType = "Spill Occurred", - Message = "Spill occurred during execution (from last query plan stats)", - Severity = PlanWarningSeverity.Warning - }); - } - - // Memory grant warning (from plan XML) — gate at 1 GB to avoid noise on small grants - // All values are in KB, consistent with MemoryGrantInfo element - var memWarnEl = warningsEl.Element(Ns + "MemoryGrantWarning"); - if (memWarnEl != null) - { - var kind = memWarnEl.Attribute("GrantWarningKind")?.Value ?? "Unknown"; - var requested = ParseLong(memWarnEl.Attribute("RequestedMemory")?.Value); - var granted = ParseLong(memWarnEl.Attribute("GrantedMemory")?.Value); - var maxUsed = ParseLong(memWarnEl.Attribute("MaxUsedMemory")?.Value); - if (granted >= 1048576) // 1 GB in KB - { - var grantedMB = granted / 1024.0; - var usedMB = maxUsed / 1024.0; - result.Add(new PlanWarning - { - WarningType = "Memory Grant", - Message = $"{kind}: Granted {grantedMB:N0} MB, Used {usedMB:N0} MB", - Severity = PlanWarningSeverity.Warning - }); - } - } - - // Implicit conversions - foreach (var convertEl in warningsEl.Elements(Ns + "PlanAffectingConvert")) - { - var issue = convertEl.Attribute("ConvertIssue")?.Value ?? "Unknown"; - var expr = convertEl.Attribute("Expression")?.Value ?? ""; - result.Add(new PlanWarning - { - WarningType = "Implicit Conversion", - Message = $"{issue}: {expr}", - Severity = issue.Contains("Cardinality") ? PlanWarningSeverity.Warning : PlanWarningSeverity.Critical - }); - } - - // Columns with no statistics - var noStatsEl = warningsEl.Element(Ns + "ColumnsWithNoStatistics"); - if (noStatsEl != null) - { - var cols = noStatsEl.Elements(Ns + "ColumnReference") - .Select(c => c.Attribute("Column")?.Value ?? "") - .Where(s => !string.IsNullOrEmpty(s)); - result.Add(new PlanWarning - { - WarningType = "Missing Statistics", - Message = $"No statistics on: {string.Join(", ", cols)}", - Severity = PlanWarningSeverity.Warning - }); - } - - // Wave 2.3: Columns with stale statistics - var staleStatsEl = warningsEl.Element(Ns + "ColumnsWithStaleStatistics"); - if (staleStatsEl != null) - { - var cols = staleStatsEl.Elements(Ns + "ColumnReference") - .Select(c => c.Attribute("Column")?.Value ?? "") - .Where(s => !string.IsNullOrEmpty(s)); - result.Add(new PlanWarning - { - WarningType = "Stale Statistics", - Message = $"Stale statistics on: {string.Join(", ", cols)}", - Severity = PlanWarningSeverity.Warning - }); - } - - // Wait warnings - foreach (var waitEl in warningsEl.Elements(Ns + "Wait")) - { - result.Add(new PlanWarning - { - WarningType = "Wait", - Message = $"{waitEl.Attribute("WaitType")?.Value}: {waitEl.Attribute("WaitTime")?.Value}ms", - Severity = PlanWarningSeverity.Info - }); - } - - return result; - } - - private static void ComputeOperatorCosts(ParsedPlan plan) - { - foreach (var batch in plan.Batches) - { - foreach (var stmt in batch.Statements) - { - if (stmt.RootNode == null) continue; - var totalCost = stmt.StatementSubTreeCost > 0 - ? stmt.StatementSubTreeCost - : stmt.RootNode.EstimatedTotalSubtreeCost; - if (totalCost <= 0) totalCost = 1; - ComputeNodeCosts(stmt.RootNode, totalCost); - } - } - } - - private static void ComputeNodeCosts(PlanNode node, double totalStatementCost) - { - var childrenSubtreeCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); - node.EstimatedOperatorCost = Math.Max(0, node.EstimatedTotalSubtreeCost - childrenSubtreeCost); - node.CostPercent = (int)Math.Round((node.EstimatedOperatorCost / totalStatementCost) * 100); - node.CostPercent = Math.Min(100, Math.Max(0, node.CostPercent)); - - foreach (var child in node.Children) - ComputeNodeCosts(child, totalStatementCost); - } - - private static IEnumerable ScopedDescendants(XElement element, XName name) - { - foreach (var child in element.Elements()) - { - if (child.Name == Ns + "RelOp") continue; - if (child.Name == name) yield return child; - foreach (var desc in ScopedDescendants(child, name)) - yield return desc; - } - } - - private static string? ParseColumnList(XElement parent, string elementName) - { - var el = parent.Element(Ns + elementName); - if (el == null) return null; - var cols = el.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - var result = string.Join(", ", cols); - return string.IsNullOrEmpty(result) ? null : result; - } - - private static string FormatColumnRef(XElement colRef) - { - var col = colRef.Attribute("Column")?.Value ?? ""; - var tbl = colRef.Attribute("Table")?.Value ?? ""; - var result = string.IsNullOrEmpty(tbl) ? col : $"{tbl}.{col}"; - return result.Replace("[", "").Replace("]", ""); - } - - private static double ParseDouble(string? value) - { - if (string.IsNullOrEmpty(value)) return 0; - return double.TryParse(value, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : 0; - } - - private static long ParseLong(string? value) - { - if (string.IsNullOrEmpty(value)) return 0; - return long.TryParse(value, System.Globalization.NumberStyles.Integer, - System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : 0; - } } diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 9e23c72..ebb8afe 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -24,6 +24,10 @@ + + + +