diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index a475b2d..debb21c 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -485,6 +485,27 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1) iconRow.Children.Add(parBadge); } + // Nonclustered index count badge (modification operators maintaining multiple NC indexes) + if (node.NonClusteredIndexCount > 0) + { + var ncBadge = new Border + { + Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(4, 1), + Margin = new Thickness(4, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = $"+{node.NonClusteredIndexCount} NC", + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White + } + }; + iconRow.Children.Add(ncBadge); + } + stack.Children.Add(iconRow); // Operator name @@ -961,7 +982,7 @@ private void ShowPropertiesPanel(PlanNode node) || node.SortDistinct || node.StartupExpression || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || node.WithTies || node.Remoting || node.LocalParallelism - || node.SpoolStack || node.DMLRequestSort + || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0 || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0 || !string.IsNullOrEmpty(node.ConstantScanValues) || !string.IsNullOrEmpty(node.UdxUsedColumns); @@ -1010,6 +1031,12 @@ private void ShowPropertiesPanel(PlanNode node) AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}"); if (node.DMLRequestSort) AddPropertyRow("DML Request Sort", "True"); + if (node.NonClusteredIndexCount > 0) + { + AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}"); + foreach (var ixName in node.NonClusteredIndexNames) + AddPropertyRow("", ixName, isCode: true); + } if (!string.IsNullOrEmpty(node.ActionColumn)) AddPropertyRow("Action Column", node.ActionColumn, isCode: true); if (!string.IsNullOrEmpty(node.SegmentColumn)) @@ -2025,6 +2052,10 @@ private object BuildNodeTooltipContent(PlanNode node, List? allWarn AddTooltipRow(stack, "Scan Direction", node.ScanDirection); } + // NC index maintenance count + if (node.NonClusteredIndexCount > 0) + AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames)); + // Operator details (key items only in tooltip) var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy) || !string.IsNullOrEmpty(node.TopExpression) diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index 0f15fbd..ec0201b 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -338,6 +338,10 @@ public class PlanNode public int NoMatchingIndexCount { get; set; } public int PartialMatchingIndexCount { get; set; } + // Modification operator: nonclustered indexes maintained + public int NonClusteredIndexCount { get; set; } + public List NonClusteredIndexNames { get; set; } = new(); + // ConstantScan Values (parsed rows as displayable string) public string? ConstantScanValues { get; set; } diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 637df45..5cf9f11 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -691,6 +691,22 @@ private static PlanNode ParseRelOp(XElement relOpEl) 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) diff --git a/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs new file mode 100644 index 0000000..5df2bd3 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs @@ -0,0 +1,54 @@ +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Tests; + +public class NonClusteredIndexCountTests +{ + [Fact] + public void Update_WithFiveNonClusteredIndexes_CountIsFive() + { + var plan = PlanTestHelper.LoadAndAnalyze("multi_index_update_plan.sqlplan"); + var stmt = PlanTestHelper.FirstStatement(plan); + var updateNode = PlanTestHelper.FindNode(stmt.RootNode!, 1)!; + + Assert.Contains("Update", updateNode.PhysicalOp, StringComparison.OrdinalIgnoreCase); + Assert.Equal(5, updateNode.NonClusteredIndexCount); + } + + [Fact] + public void Insert_WithFiveNonClusteredIndexes_CountIsFive() + { + var plan = PlanTestHelper.LoadAndAnalyze("multi_index_insert_plan.sqlplan"); + var stmt = PlanTestHelper.FirstStatement(plan); + var insertNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!; + + Assert.Contains("Insert", insertNode.PhysicalOp, StringComparison.OrdinalIgnoreCase); + Assert.Equal(5, insertNode.NonClusteredIndexCount); + } + + [Fact] + public void Delete_WithFiveNonClusteredIndexes_CountIsFive() + { + var plan = PlanTestHelper.LoadAndAnalyze("multi_index_delete_plan.sqlplan"); + var stmt = PlanTestHelper.FirstStatement(plan); + var deleteNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!; + + Assert.Contains("Delete", deleteNode.PhysicalOp, StringComparison.OrdinalIgnoreCase); + Assert.Equal(5, deleteNode.NonClusteredIndexCount); + } + + [Fact] + public void ReadOperator_HasZeroNonClusteredIndexCount() + { + var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan"); + var stmt = PlanTestHelper.FirstStatement(plan); + + void AssertZero(PlanNode node) + { + Assert.Equal(0, node.NonClusteredIndexCount); + foreach (var child in node.Children) + AssertZero(child); + } + AssertZero(stmt.RootNode!); + } +} diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan new file mode 100644 index 0000000..7444937 Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan differ diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan new file mode 100644 index 0000000..6473047 Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan differ diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan new file mode 100644 index 0000000..0201133 Binary files /dev/null and b/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan differ