From f7f6a228d69717408670b91d769de67808e44511 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 08:55:18 -0500 Subject: [PATCH] Sync plan viewer fixes from plan-b: spool labels, unmatched index detail - Spool operators now show Eager/Lazy prefix (e.g., "Eager Index Spool" instead of just "Index Spool") by prepending from LogicalOp - PlanIconMapper entries added for all Eager/Lazy spool variants - UnmatchedIndexes warning now parses child Parameterization elements to show specific database.schema.table.index names Co-Authored-By: Claude Opus 4.6 --- Dashboard/Services/PlanIconMapper.cs | 6 +++++ Dashboard/Services/ShowPlanParser.cs | 37 +++++++++++++++++++++++++++- Lite/Services/PlanIconMapper.cs | 6 +++++ Lite/Services/ShowPlanParser.cs | 37 +++++++++++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 659a9014..6ed411e3 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index 37f367c0..048ead70 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) 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; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -1429,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + 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 = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); } diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs index 7c542857..f187eda1 100644 --- a/Lite/Services/PlanIconMapper.cs +++ b/Lite/Services/PlanIconMapper.cs @@ -30,6 +30,8 @@ public static class PlanIconMapper ["Index Scan"] = "index_scan", ["Index Seek"] = "index_seek", ["Index Spool"] = "index_spool", + ["Eager Index Spool"] = "index_spool", + ["Lazy Index Spool"] = "index_spool", ["Index Update"] = "index_update", // Columnstore @@ -74,7 +76,11 @@ public static class PlanIconMapper // Spool ["Table Spool"] = "table_spool", + ["Eager Table Spool"] = "table_spool", + ["Lazy Table Spool"] = "table_spool", ["Row Count Spool"] = "row_count_spool", + ["Eager Row Count Spool"] = "row_count_spool", + ["Lazy Row Count Spool"] = "row_count_spool", ["Window Spool"] = "table_spool", ["Eager Spool"] = "table_spool", ["Lazy Spool"] = "table_spool", diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index 14625899..ef7f805e 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl) 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; + } + // Map to icon node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); @@ -1429,10 +1442,32 @@ private static List ParseWarningsFromElement(XElement warningsEl) if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1") { + 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 = "Indexes could not be matched due to parameterization", + Message = unmatchedMsg, Severity = PlanWarningSeverity.Warning }); }