From 3e33ed7cf8fce64104cf518ea95d5e8b9c8e126f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:36:07 +0100 Subject: [PATCH 01/16] Surface Query Store plan-load failures instead of silent "No Plan Loaded" (#367) Loading a plan from the Query Store grid could blank to "No Plan Loaded" while the grid vanished, with no indication why. Two compounding causes: - PlanViewerControl.LoadPlan treated a failed parse identically to an empty plan: it never inspected ParsedPlan.ParseError (which ShowPlanParser sets instead of throwing) and just showed the empty state on zero statements. - AddPlanTab unconditionally added and selected the new plan tab even when the load failed, navigating away from the grid to a blank tab. (Commit e8e5a21's parser hardening was the regression vector: it converted a tree-walk exception on a bad plan from a visible crash into a silent zero-statement ParseError, producing exactly this symptom.) LoadPlan now returns bool and exposes LastLoadError, distinguishing blank/NULL plan XML, a parse error (surfaced verbatim), and parsed-but-no-statements. AddPlanTab no longer switches away on failure -- it keeps the grid active and leaves a persistent status explaining why. Clear() resets the empty state so a reused viewer cannot show a stale error. Co-authored-by: Claude Opus 4.8 (1M context) --- .../Controls/PlanViewerControl.axaml | 9 ++- .../Controls/PlanViewerControl.axaml.cs | 68 +++++++++++++++++-- .../Controls/QuerySessionControl.Plans.cs | 13 +++- .../QuerySessionControl.QueryStore.cs | 10 ++- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index d7cae00..56de3c7 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -328,12 +328,15 @@ - - + - + diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index f6925f4..befd5ea 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -186,6 +186,13 @@ public PlanViewerControl() /// public ParsedPlan? CurrentPlan => _currentPlan; + /// + /// Reason the most recent failed (blank XML, parse error, + /// or no renderable statements), or null when it succeeded. Lets callers surface + /// why a plan didn't load instead of silently showing the empty state. + /// + public string? LastLoadError { get; private set; } + /// /// Exposes the query text associated with this plan (if any). /// @@ -298,15 +305,40 @@ public void NavigateToNode(int nodeId) }); } - public void LoadPlan(string planXml, string label, string? queryText = null) + /// + /// Parses and renders a plan. Returns true when a plan was rendered; false when the + /// XML was blank, failed to parse, or contained no renderable statements (in which case + /// the empty state explains why and holds the reason). + /// + public bool LoadPlan(string planXml, string label, string? queryText = null) { _label = label; _queryText = queryText; + LastLoadError = null; // Query text stored for copy/repro but no longer shown in a // separate expander — it's already visible in the Statements grid. + // A Query Store row can have a NULL/empty query_plan; don't treat that + // (or a parse failure) as a silent "No Plan Loaded". + if (string.IsNullOrWhiteSpace(planXml)) + { + LastLoadError = "The plan is empty — this source has no stored query plan XML."; + ShowEmptyState("Couldn't Load Plan", LastLoadError); + return false; + } + _currentPlan = ShowPlanParser.Parse(planXml); + + // ShowPlanParser never throws; it records failures in ParseError and returns an + // empty plan. Surface that instead of rendering a blank "No Plan Loaded" panel. + if (!string.IsNullOrEmpty(_currentPlan.ParseError)) + { + LastLoadError = _currentPlan.ParseError; + ShowEmptyState("Couldn't Load Plan", $"Parse error: {_currentPlan.ParseError}"); + return false; + } + PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load(), _serverMetadata); BenefitScorer.Score(_currentPlan); @@ -317,9 +349,9 @@ public void LoadPlan(string planXml, string label, string? queryText = null) if (allStatements.Count == 0) { - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; - return; + LastLoadError = "The plan parsed but contains no statements to display."; + ShowEmptyState("No Plan Loaded", null); + return false; } EmptyState.IsVisible = false; @@ -355,6 +387,30 @@ public void LoadPlan(string planXml, string label, string? queryText = null) CriticalWarningCount = criticalCount, MissingIndexCount = _currentPlan.AllMissingIndexes.Count }); + + return true; + } + + /// + /// Shows the empty-state panel with a title and, optionally, an error detail line. + /// When is null the normal "open a file" hint is shown instead. + /// + private void ShowEmptyState(string title, string? error) + { + EmptyStateTitle.Text = title; + if (string.IsNullOrEmpty(error)) + { + EmptyStateError.IsVisible = false; + EmptyStateHint.IsVisible = true; + } + else + { + EmptyStateError.Text = error; + EmptyStateError.IsVisible = true; + EmptyStateHint.IsVisible = false; + } + EmptyState.IsVisible = true; + PlanScrollViewer.IsVisible = false; } public void Clear() @@ -367,8 +423,8 @@ public void Clear() _queryText = null; _selectedNodeBorder = null; _selectedNode = null; - EmptyState.IsVisible = true; - PlanScrollViewer.IsVisible = false; + LastLoadError = null; + ShowEmptyState("No Plan Loaded", null); InsightsPanel.IsVisible = false; CostText.Text = ""; CloseStatementsPanel(); diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs index 6965639..b343fdf 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs @@ -30,7 +30,7 @@ namespace PlanViewer.App.Controls; public partial class QuerySessionControl : UserControl { - private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) + private bool AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) { _planCounter++; var label = labelOverride ?? (estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"); @@ -42,7 +42,15 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string if (_serverConnection != null) viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); viewer.OpenInEditorRequested += OnOpenInEditorRequested; - viewer.LoadPlan(planXml, label, queryText); + + if (!viewer.LoadPlan(planXml, label, queryText)) + { + // Blank XML or a parse failure. Don't navigate away from the current view + // (e.g. the Query Store grid) to a blank tab — surface why and stay put. + viewer.OpenInEditorRequested -= OnOpenInEditorRequested; + SetStatus($"Couldn't load {label}: {viewer.LastLoadError}", autoClear: false); + return false; + } // Build tab header with close button and right-click rename var headerText = new TextBlock @@ -101,6 +109,7 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string SubTabControl.Items.Add(tab); SubTabControl.SelectedItem = tab; UpdateCompareButtonState(); + return true; } private void StartRename(StackPanel header, TextBlock headerText) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs index 4d68b49..3f54695 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -254,13 +254,19 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) private void OnQueryStorePlansSelected(object? sender, List plans) { + int loaded = 0; foreach (var qsPlan in plans) { var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; - AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel); + if (AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel)) + loaded++; } - SetStatus($"{plans.Count} Query Store plans loaded"); + // Only show the success summary when every plan loaded; otherwise AddPlanTab has + // already left a persistent status explaining the failure — don't clobber it. + if (loaded == plans.Count) + SetStatus($"{plans.Count} Query Store plans loaded"); + HumanAdviceButton.IsEnabled = true; RobotAdviceButton.IsEnabled = true; } From 970d68f06f07042bee1fff3bb88cc35f47b9cbcd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:29:30 -0400 Subject: [PATCH 02/16] Bump all dependencies that can move (everything except Avalonia 12 / VSSDK 18) (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of every outdated package across all 8 projects to its current stable, holding back only the two that are genuinely blocked. App: - Avalonia + Desktop/Themes.Fluent/Fonts.Inter/Diagnostics 11.3.14 -> 11.3.17 - Meziantou.Framework.Win32.CredentialManager 1.7.18 -> 2.0.1 - ModelContextProtocol (+ AspNetCore) 1.3.0 -> 1.4.0 - Microsoft.SqlServer.TransactSql.ScriptDom 180.6.0 -> 180.37.3 - Velopack 0.0.1298 -> 1.2.0 (+ pin vpk CLI to 1.2.0 in release.yml) - TextMateSharp.Grammars 2.0.3 -> 2.0.4 - AvaloniaEdit.TextMate.Grammars 0.10.12 -> 0.10.12.1 - SkiaSharp.NativeAssets.Linux 3.119.2 -> 3.119.4 Core: - Meziantou.Framework.Win32.CredentialManager 1.7.18 -> 2.0.1 - Microsoft.SqlServer.TransactSql.ScriptDom 180.6.0 -> 180.37.3 Cli: System.CommandLine 2.0.7 -> 2.0.9 Web: Microsoft.AspNetCore.Components.WebAssembly (+ DevServer) 10.0.0 -> 10.0.9 PlanShare: Microsoft.Data.Sqlite 10.0.5 -> 10.0.9 Tests: Microsoft.NET.Test.Sdk 18.5.1 -> 18.6.0, coverlet.collector 10.0.0 -> 10.0.1 SSMS VSIX: Microsoft.VisualStudio.SDK -> 17.14.40265, Microsoft.VSSDK.BuildTools 17.11.435 -> 17.14.2142 (latest 17.x; 18.x is un-restorable and targets VS 18) Held back (blocked, not skipped): - Avalonia 12.x family — ScottPlot.Avalonia has no v12 build (charts render blank); migration is done and parked on upgrade/avalonia-12. - Microsoft.VSSDK.BuildTools 18.x — un-restorable from nuget.org. Already-current (no change): Microsoft.Data.SqlClient 7.0.1, SqlClient.Extensions.Azure 1.0.0, ScottPlot.Avalonia 5.1.58, Avalonia.AvaloniaEdit/TextMate 11.4.1, Avalonia.Controls.DataGrid 11.3.13, xunit.v3 3.2.2, xunit.runner.visualstudio 3.1.5, Microsoft.NETFramework.ReferenceAssemblies 1.0.3. Verified: solution Release build clean (0 errors, no NuGet conflicts), PlanShare builds, SSMS restores on 17.x, 77/77 tests pass, app launches and renders. Velopack/Meziantou majors need no code changes. Full install->update->relaunch validation happens at the next release cut (inherent to any updater bump). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 4 ++- server/PlanShare/PlanShare.csproj | 2 +- src/PlanViewer.App/PlanViewer.App.csproj | 26 +++++++++---------- src/PlanViewer.Cli/PlanViewer.Cli.csproj | 2 +- src/PlanViewer.Core/PlanViewer.Core.csproj | 4 +-- src/PlanViewer.Ssms/PlanViewer.Ssms.csproj | 4 +-- src/PlanViewer.Web/PlanViewer.Web.csproj | 4 +-- .../PlanViewer.Core.Tests.csproj | 4 +-- 8 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ee0fbf..db5f207 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,7 +173,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.VERSION }} run: | - dotnet tool install -g vpk + # Pin vpk to match the Velopack PackageReference (Velopack recommends the + # CLI and library versions match for compatible packages + reproducible releases). + dotnet tool install -g vpk --version 1.2.0 New-Item -ItemType Directory -Force -Path releases/velopack # Download previous release for delta generation diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj index a246dc0..598b444 100644 --- a/server/PlanShare/PlanShare.csproj +++ b/server/PlanShare/PlanShare.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index b652f3a..0536d09 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -9,33 +9,33 @@ - + - - - + + + - + None All - - - - - - - + + + + + + + - + diff --git a/src/PlanViewer.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index f793c13..4cf474d 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index aab3051..b0dc1c1 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -9,9 +9,9 @@ - + - + diff --git a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj index a67373e..36d1389 100644 --- a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj +++ b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj @@ -75,8 +75,8 @@ - - + + diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 17fe7e4..747e47f 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index 474512c..80553b3 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -10,8 +10,8 @@ - - + + From 5df509b5640a7f68d549d674be1a18ac745faac6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:44:29 -0400 Subject: [PATCH 03/16] Fix SSMS extension version targeting so the gallery shows SSMS 21/22 (#370) (#371) The InstallationTarget version under Microsoft.VisualStudio.Ssms is the SSMS product version, not the VS shell version. The old [17.0,) installed fine (21 and 22 are both >= 17) but the SSMS Gallery rendered the lower bound literally as "Supports SSMS 17 SSMS 18". Switch to [21.0,23.0) (the form ErikEJ's SqlCeToolbox uses) so the gallery shows "SSMS 21 / SSMS 22", and clarify the description. Verified end to end: rebuilt the VSIX and confirmed SSMS 22's VSIXInstaller accepts [21.0,23.0) (uninstall + reinstall both exit 0). Co-authored-by: Claude Opus 4.8 (1M context) --- src/PlanViewer.Ssms/source.extension.vsixmanifest | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index be2ecb2..f20fe4e 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -7,18 +7,18 @@ Language="en-US" Publisher="Darling Data" /> Performance Studio for SSMS - Adds "Open in Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in Performance Studio for advanced analysis. + Adds "Open in Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in Performance Studio for advanced analysis. Compatible with SSMS 21 and SSMS 22. https://github.com/erikdarlingdata/PerformanceStudio LICENSE Resources\PerformanceStudioIcon.png SQL Server, Execution Plan, Performance, SSMS - - + + amd64 - + arm64 From 04cb828359b621d4046503a2b95d56c84c361421 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:22:15 -0400 Subject: [PATCH 04/16] Extract shared NodeTimeAttribution service to Core The per-operator "own time" CPU/elapsed calculation was implemented separately in the desktop plan viewer (PlanViewerControl) and the web plan viewer (Index.razor), with the desktop version handling exchange operators via worker-thread data and the web version lacking that. Consolidate both onto a single Core service (NodeTimeAttribution) ported from the more-correct desktop implementation, so desktop and web report identical operator timings. The web's node coloring/timing display now matches the desktop, including parallelism exchange handling. The analysis pipeline (PlanAnalyzer.Timing) and the OperatorResult-based text exporter (TextFormatter) keep their own implementations by design. NodeTimeAttribution.cs is linked into PlanViewer.Web (Blazor WASM links Core sources individually rather than referencing the assembly). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controls/PlanViewerControl.Properties.cs | 88 -------------- .../Controls/PlanViewerControl.Rendering.cs | 4 +- .../Services/NodeTimeAttribution.cs | 114 ++++++++++++++++++ src/PlanViewer.Web/Pages/Index.razor | 34 +----- src/PlanViewer.Web/PlanViewer.Web.csproj | 1 + 5 files changed, 119 insertions(+), 122 deletions(-) create mode 100644 src/PlanViewer.Core/Services/NodeTimeAttribution.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs index 24a05d4..35c1dfd 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs @@ -1363,94 +1363,6 @@ private static void CollectTableVariableNames(PlanNode node, HashSet nam CollectTableVariableNames(child, names); } - /// - /// Computes own CPU time for a node by subtracting child times in row mode. - /// Batch mode reports own time directly; row mode is cumulative from leaves up. - /// - private static long GetOwnCpuMs(PlanNode node) - { - if (node.ActualCPUMs <= 0) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualCPUMs; - var childSum = GetChildCpuMsSum(node); - return Math.Max(0, node.ActualCPUMs - childSum); - } - - /// - /// Computes own elapsed time for a node by subtracting child times in row mode. - /// - private static long GetOwnElapsedMs(PlanNode node) - { - if (node.ActualElapsedMs <= 0) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualElapsedMs; - - // Exchange operators: Thread 0 is the coordinator whose elapsed time is the - // wall clock for the entire parallel branch — not the operator's own work. - if (IsExchangeOperator(node)) - { - // If we have worker thread data, use max of worker threads - var workerMax = node.PerThreadStats - .Where(t => t.ThreadId > 0) - .Select(t => t.ActualElapsedMs) - .DefaultIfEmpty(0) - .Max(); - if (workerMax > 0) - { - var childSum = GetChildElapsedMsSum(node); - return Math.Max(0, workerMax - childSum); - } - // Thread 0 only (coordinator) — exchange does negligible own work - return 0; - } - - var childElapsedSum = GetChildElapsedMsSum(node); - return Math.Max(0, node.ActualElapsedMs - childElapsedSum); - } - - private static bool IsExchangeOperator(PlanNode node) => - node.PhysicalOp == "Parallelism" - || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams"; - - private static long GetChildCpuMsSum(PlanNode node) - { - long sum = 0; - foreach (var child in node.Children) - { - if (child.ActualCPUMs > 0) - sum += child.ActualCPUMs; - else - sum += GetChildCpuMsSum(child); // skip through transparent operators - } - return sum; - } - - private static long GetChildElapsedMsSum(PlanNode node) - { - long sum = 0; - foreach (var child in node.Children) - { - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - { - // Exchange: take max of children (parallel branches) - sum += child.Children - .Where(c => c.ActualElapsedMs > 0) - .Select(c => c.ActualElapsedMs) - .DefaultIfEmpty(0) - .Max(); - } - else if (child.ActualElapsedMs > 0) - { - sum += child.ActualElapsedMs; - } - else - { - sum += GetChildElapsedMsSum(child); // skip through transparent operators - } - } - return sum; - } - private void ShowWaitStats(List waits, List benefits, bool isActualPlan) { WaitStatsContent.Children.Clear(); diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index a747e7f..6955563 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -265,8 +265,8 @@ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int total if (node.HasActualStats) { // Compute own time (subtract children in row mode) - var ownElapsedMs = GetOwnElapsedMs(node); - var ownCpuMs = GetOwnCpuMs(node); + var ownElapsedMs = NodeTimeAttribution.GetOwnElapsedMs(node); + var ownCpuMs = NodeTimeAttribution.GetOwnCpuMs(node); // Elapsed time -- color based on own time, not cumulative var ownElapsedSec = ownElapsedMs / 1000.0; diff --git a/src/PlanViewer.Core/Services/NodeTimeAttribution.cs b/src/PlanViewer.Core/Services/NodeTimeAttribution.cs new file mode 100644 index 0000000..5e30d1c --- /dev/null +++ b/src/PlanViewer.Core/Services/NodeTimeAttribution.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +/// +/// Computes a plan operator's "own" (self) CPU and elapsed time from its +/// cumulative actual stats by subtracting child contributions in row mode. +/// Batch mode reports self-time directly; row mode is cumulative from the leaves up. +/// Shared by the desktop and web plan viewers so both surfaces report identical +/// per-operator timings. +/// +/// +/// This is the display-grade attribution used for node coloring and the +/// properties panel. The analysis pipeline uses the more elaborate per-thread +/// calculation in (GetOperatorOwnElapsedMs / +/// GetOperatorMaxThreadOwnCpuMs); the two are intentionally separate. +/// +public static class NodeTimeAttribution +{ + /// + /// Computes own CPU time for a node by subtracting child times in row mode. + /// Batch mode reports own time directly; row mode is cumulative from leaves up. + /// + public static long GetOwnCpuMs(PlanNode node) + { + if (node.ActualCPUMs <= 0) return 0; + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (mode == "Batch") return node.ActualCPUMs; + var childSum = GetChildCpuMsSum(node); + return Math.Max(0, node.ActualCPUMs - childSum); + } + + /// + /// Computes own elapsed time for a node by subtracting child times in row mode. + /// + public static long GetOwnElapsedMs(PlanNode node) + { + if (node.ActualElapsedMs <= 0) return 0; + var mode = node.ActualExecutionMode ?? node.ExecutionMode; + if (mode == "Batch") return node.ActualElapsedMs; + + // Exchange operators: Thread 0 is the coordinator whose elapsed time is the + // wall clock for the entire parallel branch — not the operator's own work. + if (IsExchangeOperator(node)) + { + // If we have worker thread data, use max of worker threads + var workerMax = node.PerThreadStats + .Where(t => t.ThreadId > 0) + .Select(t => t.ActualElapsedMs) + .DefaultIfEmpty(0) + .Max(); + if (workerMax > 0) + { + var childSum = GetChildElapsedMsSum(node); + return Math.Max(0, workerMax - childSum); + } + // Thread 0 only (coordinator) — exchange does negligible own work + return 0; + } + + var childElapsedSum = GetChildElapsedMsSum(node); + return Math.Max(0, node.ActualElapsedMs - childElapsedSum); + } + + /// + /// True for parallelism exchange operators (Gather/Distribute/Repartition Streams), + /// whose coordinator-thread timings don't reflect the operator's own work. + /// + public static bool IsExchangeOperator(PlanNode node) => + node.PhysicalOp == "Parallelism" + || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams"; + + private static long GetChildCpuMsSum(PlanNode node) + { + long sum = 0; + foreach (var child in node.Children) + { + if (child.ActualCPUMs > 0) + sum += child.ActualCPUMs; + else + sum += GetChildCpuMsSum(child); // skip through transparent operators + } + return sum; + } + + private static long GetChildElapsedMsSum(PlanNode node) + { + long sum = 0; + foreach (var child in node.Children) + { + if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) + { + // Exchange: take max of children (parallel branches) + sum += child.Children + .Where(c => c.ActualElapsedMs > 0) + .Select(c => c.ActualElapsedMs) + .DefaultIfEmpty(0) + .Max(); + } + else if (child.ActualElapsedMs > 0) + { + sum += child.ActualElapsedMs; + } + else + { + sum += GetChildElapsedMsSum(child); // skip through transparent operators + } + } + return sum; + } +} diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index c130739..8f50ace 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -2073,8 +2073,8 @@ else // Actual plan stats if (node.HasActualStats) { - var ownElapsedMs = GetOwnElapsedMs(node); - var ownCpuMs = GetOwnCpuMs(node); + var ownElapsedMs = NodeTimeAttribution.GetOwnElapsedMs(node); + var ownCpuMs = NodeTimeAttribution.GetOwnCpuMs(node); var ownElapsedSec = ownElapsedMs / 1000.0; var ownCpuSec = ownCpuMs / 1000.0; @@ -2182,36 +2182,6 @@ else return string.Join("\n", parts); } - private static long GetOwnElapsedMs(PlanNode node) - { - if (!node.HasActualStats) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualElapsedMs; - long childSum = 0; - foreach (var child in node.Children) - { - if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0) - childSum += child.Children.Where(c => c.HasActualStats).Select(c => c.ActualElapsedMs).DefaultIfEmpty(0).Max(); - else if (child.HasActualStats) childSum += child.ActualElapsedMs; - else childSum += GetOwnElapsedMs(child); - } - return Math.Max(0, node.ActualElapsedMs - childSum); - } - - private static long GetOwnCpuMs(PlanNode node) - { - if (!node.HasActualStats || node.ActualCPUMs == 0) return 0; - var mode = node.ActualExecutionMode ?? node.ExecutionMode; - if (mode == "Batch") return node.ActualCPUMs; - long childSum = 0; - foreach (var child in node.Children) - { - if (child.HasActualStats && child.ActualCPUMs > 0) childSum += child.ActualCPUMs; - else childSum += GetOwnCpuMs(child); - } - return Math.Max(0, node.ActualCPUMs - childSum); - } - private static List GetAllWarnings(StatementResult stmt) { var warnings = new List(stmt.Warnings); diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 747e47f..224029d 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -30,6 +30,7 @@ + From 28cb112b93478ae007af2b6016b9b3d805269cb4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:39:24 -0400 Subject: [PATCH 05/16] Split Index.razor god file into components + share service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index.razor was a 2342-line god file mixing a route page, a 1270-line operator-properties inspector, insights/warnings panels, share modals, and all the HTTP/JSON share plumbing. Break it apart: - OperatorPropertiesPanel.razor — the ~1270-line, ~40-section operator inspector, parameterized by Node/StmtPlan/Plan/IsRoot/OnClose. - InsightsPanel.razor / WarningsStrip.razor — the runtime/indexes/params/ waits cards and the warnings strip. - PlanViewFormat — stateless formatting/predicate helpers shared across the page and components, imported via `@using static` so call sites are unchanged. - IPlanShareService / PlanShareService — the share/delete/load HTTP+JSON logic, registered in DI with a single shared HttpClient instead of a `new HttpClient()` per call. UI concerns (clipboard, state, errors) stay in the page; the service throws PlanShareException with user-facing text. Index.razor drops from 2342 to 591 lines with no behavior change (markup moved verbatim, identifiers reparameterized). Remaining optional splits (PlanInputView, ShareDialog, PlanTree) deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.Web/Pages/Index.razor | 1767 +---------------- src/PlanViewer.Web/Pages/InsightsPanel.razor | 216 ++ .../Pages/OperatorPropertiesPanel.razor | 1278 ++++++++++++ src/PlanViewer.Web/Pages/WarningsStrip.razor | 46 + src/PlanViewer.Web/PlanViewFormat.cs | 182 ++ src/PlanViewer.Web/Program.cs | 5 + .../Services/PlanShareService.cs | 92 + src/PlanViewer.Web/_Imports.razor | 2 + 8 files changed, 1844 insertions(+), 1744 deletions(-) create mode 100644 src/PlanViewer.Web/Pages/InsightsPanel.razor create mode 100644 src/PlanViewer.Web/Pages/OperatorPropertiesPanel.razor create mode 100644 src/PlanViewer.Web/Pages/WarningsStrip.razor create mode 100644 src/PlanViewer.Web/PlanViewFormat.cs create mode 100644 src/PlanViewer.Web/Services/PlanShareService.cs diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 8f50ace..ffa6b28 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -1,5 +1,6 @@ @page "/" @inject IJSRuntime JS +@inject IPlanShareService ShareService @if (result == null) { @@ -140,264 +141,13 @@ else } - @* Insights panel — horizontal cards matching desktop layout *@ -
- - @* Runtime Summary *@ - @{ - var isEstimatedRuntime = ActiveStmt!.QueryTime == null; - var hasAnySpill = HasSpillInTree(ActiveStmt!.OperatorTree); - } -
-

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

-
- @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ - @if (ActiveStmt!.QueryTime != null) - { -
- Elapsed - @ActiveStmt!.QueryTime.ElapsedTimeMs.ToString("N0") ms -
- @if (ActiveStmt!.QueryTime.ElapsedTimeMs > 0) - { - var effectiveCpu = Math.Max(0L, ActiveStmt!.QueryTime.CpuTimeMs - ActiveStmt!.QueryTime.ExternalWaitMs); - var ratio = (double)effectiveCpu / ActiveStmt!.QueryTime.ElapsedTimeMs; -
- CPU:Elapsed - @ratio.ToString("N2") -
- } - } - @if (ActiveStmt!.DegreeOfParallelism > 0) - { -
- DOP - @ActiveStmt!.DegreeOfParallelism -
- } - @if (ActiveStmt!.NonParallelReason != null) - { -
- Serial - @ActiveStmt!.NonParallelReason -
- } - @if (ActiveStmt!.QueryTime != null) - { -
- CPU - @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms -
- } - @if (ActiveStmt!.CompileTimeMs > 0) - { -
- Compile - @ActiveStmt!.CompileTimeMs.ToString("N0") ms -
- } - @if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0) - { - var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100; - var effClass = GetMemoryGrantColorClass(pctUsed, hasAnySpill); -
- Memory - @FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted -
-
- Used - - @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) - @if (hasAnySpill) - { - ⚠ spill - } - -
- } - else if (isEstimatedRuntime && ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.DesiredKB > 0) - { -
- Memory (estimated) - @FormatKB(ActiveStmt!.MemoryGrant.DesiredKB) desired -
- @if (ActiveStmt!.MemoryGrant.SerialRequiredKB > 0 && ActiveStmt!.MemoryGrant.SerialRequiredKB != ActiveStmt!.MemoryGrant.DesiredKB) - { -
- Serial required - @FormatKB(ActiveStmt!.MemoryGrant.SerialRequiredKB) -
- } - } - @if (ActiveStmt!.OptimizationLevel != null) - { -
- Optimization - @ActiveStmt!.OptimizationLevel -
- } - @if (ActiveStmt!.CardinalityEstimationModel > 0) - { -
- CE Model - @ActiveStmt!.CardinalityEstimationModel -
- } -
- Cost - @ActiveStmt!.EstimatedCost.ToString("N2") -
-
-
- - @* Missing Indexes *@ -
-

Missing Indexes @ActiveStmt!.MissingIndexes.Count

-
- @if (ActiveStmt!.MissingIndexes.Count > 0) - { - @foreach (var mi in ActiveStmt!.MissingIndexes) - { -
-
@mi.Table
-
Impact: @mi.Impact.ToString("F0")%
-
@mi.CreateStatement
-
- } - } - else - { -
No missing index suggestions
- } -
-
- - @* Parameters *@ -
-

Parameters @ActiveStmt!.Parameters.Count

-
- @if (ActiveStmt!.Parameters.Count > 0) - { - - - - - - - @if (ActiveStmt!.Parameters.Any(p => p.RuntimeValue != null)) - { - - } - - - - @foreach (var p in ActiveStmt!.Parameters) - { - - - - - @if (ActiveStmt!.Parameters.Any(pp => pp.RuntimeValue != null)) - { - - } - - } - -
NameTypeCompiledRuntime
@p.Name@p.DataType@(p.CompiledValue ?? "?")@(p.RuntimeValue ?? "")
- } - else - { -
No parameters
- } -
-
- - @* Wait Stats — collapsible (#215 E12): default-closed so it doesn't push improvement items below the fold *@ -
- Wait Stats - @if (ActiveStmt!.WaitStats.Count > 0) - { - @ActiveStmt!.WaitStats.Sum(w => w.WaitTimeMs).ToString("N0") ms - } - -
- @if (ActiveStmt!.WaitStats.Count > 0) - { - var maxWait = ActiveStmt!.WaitStats.Max(w => w.WaitTimeMs); - var benefitLookup = ActiveStmt!.WaitBenefits.ToDictionary(wb => wb.WaitType, wb => wb.MaxBenefitPercent, StringComparer.OrdinalIgnoreCase); - @foreach (var w in ActiveStmt!.WaitStats.OrderByDescending(w => w.WaitTimeMs)) - { - var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; - benefitLookup.TryGetValue(w.WaitType, out var benefitPct); -
- @w.WaitType -
-
-
- - @w.WaitTimeMs.ToString("N0") ms - @if (benefitPct > 0) - { - up to @(benefitPct >= 100 ? benefitPct.ToString("N0") : benefitPct.ToString("N1"))% - } - -
- } - } - else - { -
@(result.Summary.HasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")
- } -
-
-
+ @* Insights panel *@ + @* Warnings strip *@ @if (GetAllWarnings(ActiveStmt!).Count > 0) { -
-

Warnings - @{ - var allWarns = GetAllWarnings(ActiveStmt!); - var critCount = allWarns.Count(w => w.Severity == "Critical"); - var warnCount = allWarns.Count(w => w.Severity == "Warning"); - var infoCount = allWarns.Count(w => w.Severity == "Info"); - } - @if (critCount > 0) { @critCount } - @if (warnCount > 0) { @warnCount } - @if (infoCount > 0) { @infoCount } -

-
- @foreach (var w in GetAllWarnings(ActiveStmt!) - .OrderByDescending(x => x.MaxBenefitPercent ?? -1) - .ThenByDescending(x => x.Severity == "Critical" ? 3 : x.Severity == "Warning" ? 2 : 1) - .ThenBy(x => x.Type)) - { -
- @w.Severity - @if (w.Operator != null) - { - @w.Operator - } - @w.Type - @if (w.IsLegacy) - { - legacy - } - @if (w.MaxBenefitPercent.HasValue) - { - up to @(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))% benefit - } - @w.Message - @if (!string.IsNullOrEmpty(w.ActionableFix)) - { - @w.ActionableFix - } -
- } -
-
+ } @* Statement text *@ @@ -429,1282 +179,15 @@ else @* === Operator Properties Panel === *@ @if (selectedNode != null) { - + } } @code { - private const string ShareApiBase = "https://stats.erikdarling.com"; - private static readonly string AppVersion = GetAppVersion(); private static string GetAppVersion() { @@ -1712,27 +195,6 @@ else return v == null ? "?" : $"v{v.Major}.{v.Minor}.{v.Build}"; } - // Memory grant color tiers (#215 C1 + E8 + E9): - // > 100%: over-used grant (red). Spill in plan: orange. Otherwise: tier by utilization. - private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) - { - if (pctUsed > 100) return "eff-bad"; - if (hasSpill) return "eff-warn"; - if (pctUsed >= 40) return "eff-good"; - if (pctUsed >= 20) return "eff-warn"; - return "eff-bad"; - } - - private static bool HasSpillInTree(OperatorResult? node) - { - if (node == null) return false; - foreach (var w in node.Warnings) - if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) return true; - foreach (var child in node.Children) - if (HasSpillInTree(child)) return true; - return false; - } - private string activeTab = "paste"; private string planXml = ""; private string? errorMessage; @@ -1881,33 +343,16 @@ else try { - using var http = new HttpClient(); - var payload = System.Text.Json.JsonSerializer.Serialize(new - { - result = result, - text = textOutput, - ttl_days = shareTtlDays - }); - var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); - var response = await http.PostAsync($"{ShareApiBase}/api/share", content); - - if (!response.IsSuccessStatusCode) - { - errorMessage = $"Share failed: server returned {(int)response.StatusCode}"; - return; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = System.Text.Json.JsonDocument.Parse(json); - shareId = doc.RootElement.GetProperty("id").GetString(); - deleteToken = doc.RootElement.GetProperty("delete_token").GetString(); + var share = await ShareService.ShareAsync(result, textOutput, shareTtlDays); + shareId = share.Id; + deleteToken = share.DeleteToken; var baseUrl = await JS.InvokeAsync("getBaseUrl"); shareUrl = $"{baseUrl}?share={shareId}"; await JS.InvokeVoidAsync("copyToClipboard", shareUrl); } catch (Exception ex) { - errorMessage = $"Share failed: {ex.Message}"; + errorMessage = ex is PlanShareException ? ex.Message : $"Share failed: {ex.Message}"; } finally { @@ -1923,23 +368,15 @@ else try { - using var http = new HttpClient(); - var response = await http.DeleteAsync($"{ShareApiBase}/api/plans/{shareId}?token={deleteToken}"); - if (response.IsSuccessStatusCode) - { - shareUrl = null; - shareId = null; - deleteToken = null; - errorMessage = null; - } - else - { - errorMessage = "Failed to delete shared plan."; - } + await ShareService.DeleteAsync(shareId, deleteToken); + shareUrl = null; + shareId = null; + deleteToken = null; + errorMessage = null; } catch (Exception ex) { - errorMessage = $"Delete failed: {ex.Message}"; + errorMessage = ex is PlanShareException ? ex.Message : $"Delete failed: {ex.Message}"; } finally { @@ -1947,17 +384,6 @@ else } } - private static string FormatTtl(int days) => days switch - { - 1 => "1 day", - < 30 => $"{days} days", - 30 => "1 month", - 90 => "3 months", - 180 => "6 months", - 365 => "1 year", - _ => $"{days} days" - }; - protected override async Task OnInitializedAsync() { var uri = await JS.InvokeAsync("getQueryParam", "share"); @@ -1974,29 +400,14 @@ else try { - using var http = new HttpClient(); - var response = await http.GetAsync($"{ShareApiBase}/api/plans/{id}"); - if (!response.IsSuccessStatusCode) - { - errorMessage = response.StatusCode == System.Net.HttpStatusCode.NotFound - ? "This shared plan has expired or does not exist." - : $"Failed to load shared plan: {(int)response.StatusCode}"; - isAnalyzing = false; - return; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var root = doc.RootElement; - - result = System.Text.Json.JsonSerializer.Deserialize( - root.GetProperty("result").GetRawText()); - textOutput = root.GetProperty("text").GetString(); + var shared = await ShareService.LoadAsync(id); + result = shared.Result; + textOutput = shared.Text; sourceLabel = "shared plan"; } catch (Exception ex) { - errorMessage = $"Failed to load shared plan: {ex.Message}"; + errorMessage = ex is PlanShareException ? ex.Message : $"Failed to load shared plan: {ex.Message}"; } finally { @@ -2146,77 +557,6 @@ else } }; - private static string BuildTooltip(PlanNode node) - { - var parts = new List(); - parts.Add($"{node.PhysicalOp} (Node {node.NodeId})"); - - if (!string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != node.PhysicalOp) - parts.Add($"Logical: {node.LogicalOp}"); - - if (node.HasActualStats) - { - parts.Add($"Actual rows: {node.ActualRows:N0}"); - parts.Add($"Estimated rows: {node.EstimateRows:N0}"); - if (node.ActualLogicalReads > 0) parts.Add($"Logical reads: {node.ActualLogicalReads:N0}"); - if (node.ActualPhysicalReads > 0) parts.Add($"Physical reads: {node.ActualPhysicalReads:N0}"); - } - else - { - parts.Add($"Estimated rows: {node.EstimateRows:N0}"); - } - - parts.Add($"Estimated cost: {node.EstimatedOperatorCost:N4}"); - parts.Add($"Subtree cost: {node.EstimatedTotalSubtreeCost:N4}"); - - if (!string.IsNullOrEmpty(node.ObjectName)) parts.Add($"Object: {node.FullObjectName ?? node.ObjectName}"); - if (!string.IsNullOrEmpty(node.IndexName)) parts.Add($"Index: {node.IndexName}"); - if (!string.IsNullOrEmpty(node.SeekPredicates)) parts.Add($"Seek: {node.SeekPredicates}"); - if (!string.IsNullOrEmpty(node.Predicate)) parts.Add($"Predicate: {node.Predicate}"); - if (!string.IsNullOrEmpty(node.OrderBy)) parts.Add($"Order by: {node.OrderBy}"); - if (!string.IsNullOrEmpty(node.OutputColumns)) parts.Add($"Output: {node.OutputColumns}"); - - foreach (var w in node.Warnings) - parts.Add($"[{w.Severity}] {w.WarningType}: {w.Message}"); - - return string.Join("\n", parts); - } - - private static List GetAllWarnings(StatementResult stmt) - { - var warnings = new List(stmt.Warnings); - if (stmt.OperatorTree != null) - CollectNodeWarningsRecursive(stmt.OperatorTree, warnings); - return warnings; - } - - private static List CollectNodeWarnings(OperatorResult node) - { - var warnings = new List(); - CollectNodeWarningsRecursive(node, warnings); - return warnings; - } - - private static void CollectNodeWarningsRecursive(OperatorResult node, List warnings) - { - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectNodeWarningsRecursive(child, warnings); - } - - private static string FormatKB(long kb) - { - if (kb < 1024) return $"{kb:N0} KB"; - if (kb < 1024 * 1024) return $"{kb / 1024.0:N1} MB"; - return $"{kb / (1024.0 * 1024.0):N2} GB"; - } - - private static string FormatMs(long ms) - { - if (ms < 1000) return $"{ms:N0} ms"; - return $"{ms / 1000.0:F3} s"; - } - private void SelectNode(PlanNode node) { selectedNode = selectedNode == node ? null : node; @@ -2248,65 +588,4 @@ else } } - private static string GetOperatorLabel(PlanNode node) - { - if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != "Parallelism") - return $"Parallelism ({node.LogicalOp})"; - return node.PhysicalOp; - } - - private static bool HasPredicates(PlanNode node) => - !string.IsNullOrEmpty(node.SeekPredicates) || - !string.IsNullOrEmpty(node.Predicate) || - !string.IsNullOrEmpty(node.HashKeysProbe) || - !string.IsNullOrEmpty(node.HashKeysBuild) || - !string.IsNullOrEmpty(node.BuildResidual) || - !string.IsNullOrEmpty(node.ProbeResidual) || - !string.IsNullOrEmpty(node.MergeResidual) || - !string.IsNullOrEmpty(node.PassThru) || - !string.IsNullOrEmpty(node.SetPredicate); - - private static bool HasOperatorDetails(PlanNode node) => - !string.IsNullOrEmpty(node.OrderBy) || - !string.IsNullOrEmpty(node.GroupBy) || - !string.IsNullOrEmpty(node.TopExpression) || - !string.IsNullOrEmpty(node.InnerSideJoinColumns) || - !string.IsNullOrEmpty(node.OuterSideJoinColumns) || - !string.IsNullOrEmpty(node.OuterReferences) || - !string.IsNullOrEmpty(node.DefinedValues) || - !string.IsNullOrEmpty(node.HashKeys) || - !string.IsNullOrEmpty(node.PartitionColumns) || - !string.IsNullOrEmpty(node.SegmentColumn) || - !string.IsNullOrEmpty(node.ConstantScanValues) || - !string.IsNullOrEmpty(node.ActionColumn) || - !string.IsNullOrEmpty(node.OriginalActionColumn) || - !string.IsNullOrEmpty(node.OffsetExpression) || - !string.IsNullOrEmpty(node.TvfParameters) || - !string.IsNullOrEmpty(node.UdxName) || - !string.IsNullOrEmpty(node.UdxUsedColumns) || - !string.IsNullOrEmpty(node.TieColumns) || - !string.IsNullOrEmpty(node.PartitioningType) || - !string.IsNullOrEmpty(node.PartitionId) || - !string.IsNullOrEmpty(node.StarJoinOperationType) || - !string.IsNullOrEmpty(node.ProbeColumn) || - node.ManyToMany || node.SortDistinct || node.BitmapCreator || - node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || - node.Remoting || node.LocalParallelism || node.StartupExpression || - node.DMLRequestSort || node.SpoolStack || node.WithTies || - node.IsStarJoin || node.InRow || node.ComputeSequence || - node.RowCount || node.GroupExecuted || node.RemoteDataAccess || - node.OptimizedHalloweenProtectionUsed || - node.NonClusteredIndexCount > 0 || node.TopRows > 0 || - node.RollupHighestLevel > 0 || node.ForceSeekColumnCount > 0 || - node.StatsCollectionId > 0; - - private static bool HasMemoryInfo(PlanNode node) => - (node.MemoryGrantKB.HasValue && node.MemoryGrantKB > 0) || - (node.DesiredMemoryKB.HasValue && node.DesiredMemoryKB > 0) || - (node.MaxUsedMemoryKB.HasValue && node.MaxUsedMemoryKB > 0) || - node.InputMemoryGrantKB > 0 || - node.OutputMemoryGrantKB > 0 || - node.UsedMemoryGrantKB > 0 || - node.MemoryFractionInput > 0 || - node.MemoryFractionOutput > 0; } diff --git a/src/PlanViewer.Web/Pages/InsightsPanel.razor b/src/PlanViewer.Web/Pages/InsightsPanel.razor new file mode 100644 index 0000000..4ccdfcc --- /dev/null +++ b/src/PlanViewer.Web/Pages/InsightsPanel.razor @@ -0,0 +1,216 @@ +
+ + @* Runtime Summary *@ + @{ + var isEstimatedRuntime = Stmt.QueryTime == null; + var hasAnySpill = HasSpillInTree(Stmt.OperatorTree); + } +
+

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

+
+ @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ + @if (Stmt.QueryTime != null) + { +
+ Elapsed + @Stmt.QueryTime.ElapsedTimeMs.ToString("N0") ms +
+ @if (Stmt.QueryTime.ElapsedTimeMs > 0) + { + var effectiveCpu = Math.Max(0L, Stmt.QueryTime.CpuTimeMs - Stmt.QueryTime.ExternalWaitMs); + var ratio = (double)effectiveCpu / Stmt.QueryTime.ElapsedTimeMs; +
+ CPU:Elapsed + @ratio.ToString("N2") +
+ } + } + @if (Stmt.DegreeOfParallelism > 0) + { +
+ DOP + @Stmt.DegreeOfParallelism +
+ } + @if (Stmt.NonParallelReason != null) + { +
+ Serial + @Stmt.NonParallelReason +
+ } + @if (Stmt.QueryTime != null) + { +
+ CPU + @Stmt.QueryTime.CpuTimeMs.ToString("N0") ms +
+ } + @if (Stmt.CompileTimeMs > 0) + { +
+ Compile + @Stmt.CompileTimeMs.ToString("N0") ms +
+ } + @if (Stmt.MemoryGrant != null && Stmt.MemoryGrant.GrantedKB > 0) + { + var pctUsed = (double)Stmt.MemoryGrant.MaxUsedKB / Stmt.MemoryGrant.GrantedKB * 100; + var effClass = GetMemoryGrantColorClass(pctUsed, hasAnySpill); +
+ Memory + @FormatKB(Stmt.MemoryGrant.GrantedKB) granted +
+
+ Used + + @FormatKB(Stmt.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + @if (hasAnySpill) + { + ⚠ spill + } + +
+ } + else if (isEstimatedRuntime && Stmt.MemoryGrant != null && Stmt.MemoryGrant.DesiredKB > 0) + { +
+ Memory (estimated) + @FormatKB(Stmt.MemoryGrant.DesiredKB) desired +
+ @if (Stmt.MemoryGrant.SerialRequiredKB > 0 && Stmt.MemoryGrant.SerialRequiredKB != Stmt.MemoryGrant.DesiredKB) + { +
+ Serial required + @FormatKB(Stmt.MemoryGrant.SerialRequiredKB) +
+ } + } + @if (Stmt.OptimizationLevel != null) + { +
+ Optimization + @Stmt.OptimizationLevel +
+ } + @if (Stmt.CardinalityEstimationModel > 0) + { +
+ CE Model + @Stmt.CardinalityEstimationModel +
+ } +
+ Cost + @Stmt.EstimatedCost.ToString("N2") +
+
+
+ + @* Missing Indexes *@ +
+

Missing Indexes @Stmt.MissingIndexes.Count

+
+ @if (Stmt.MissingIndexes.Count > 0) + { + @foreach (var mi in Stmt.MissingIndexes) + { +
+
@mi.Table
+
Impact: @mi.Impact.ToString("F0")%
+
@mi.CreateStatement
+
+ } + } + else + { +
No missing index suggestions
+ } +
+
+ + @* Parameters *@ +
+

Parameters @Stmt.Parameters.Count

+
+ @if (Stmt.Parameters.Count > 0) + { + + + + + + + @if (Stmt.Parameters.Any(p => p.RuntimeValue != null)) + { + + } + + + + @foreach (var p in Stmt.Parameters) + { + + + + + @if (Stmt.Parameters.Any(pp => pp.RuntimeValue != null)) + { + + } + + } + +
NameTypeCompiledRuntime
@p.Name@p.DataType@(p.CompiledValue ?? "?")@(p.RuntimeValue ?? "")
+ } + else + { +
No parameters
+ } +
+
+ + @* Wait Stats — collapsible (#215 E12): default-closed so it doesn't push improvement items below the fold *@ +
+ Wait Stats + @if (Stmt.WaitStats.Count > 0) + { + @Stmt.WaitStats.Sum(w => w.WaitTimeMs).ToString("N0") ms + } + +
+ @if (Stmt.WaitStats.Count > 0) + { + var maxWait = Stmt.WaitStats.Max(w => w.WaitTimeMs); + var benefitLookup = Stmt.WaitBenefits.ToDictionary(wb => wb.WaitType, wb => wb.MaxBenefitPercent, StringComparer.OrdinalIgnoreCase); + @foreach (var w in Stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) + { + var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; + benefitLookup.TryGetValue(w.WaitType, out var benefitPct); +
+ @w.WaitType +
+
+
+ + @w.WaitTimeMs.ToString("N0") ms + @if (benefitPct > 0) + { + up to @(benefitPct >= 100 ? benefitPct.ToString("N0") : benefitPct.ToString("N1"))% + } + +
+ } + } + else + { +
@(HasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")
+ } +
+
+
+ +@code { + [Parameter] public StatementResult Stmt { get; set; } = default!; + [Parameter] public bool HasActualStats { get; set; } +} diff --git a/src/PlanViewer.Web/Pages/OperatorPropertiesPanel.razor b/src/PlanViewer.Web/Pages/OperatorPropertiesPanel.razor new file mode 100644 index 0000000..e19a8cf --- /dev/null +++ b/src/PlanViewer.Web/Pages/OperatorPropertiesPanel.razor @@ -0,0 +1,1278 @@ + + +@code { + [Parameter] public PlanNode Node { get; set; } = default!; + [Parameter] public PlanStatement? StmtPlan { get; set; } + [Parameter] public ParsedPlan? Plan { get; set; } + [Parameter] public bool IsRoot { get; set; } + [Parameter] public EventCallback OnClose { get; set; } +} diff --git a/src/PlanViewer.Web/Pages/WarningsStrip.razor b/src/PlanViewer.Web/Pages/WarningsStrip.razor new file mode 100644 index 0000000..d746c88 --- /dev/null +++ b/src/PlanViewer.Web/Pages/WarningsStrip.razor @@ -0,0 +1,46 @@ +
+

Warnings + @{ + var allWarns = GetAllWarnings(Stmt); + var critCount = allWarns.Count(w => w.Severity == "Critical"); + var warnCount = allWarns.Count(w => w.Severity == "Warning"); + var infoCount = allWarns.Count(w => w.Severity == "Info"); + } + @if (critCount > 0) { @critCount } + @if (warnCount > 0) { @warnCount } + @if (infoCount > 0) { @infoCount } +

+
+ @foreach (var w in GetAllWarnings(Stmt) + .OrderByDescending(x => x.MaxBenefitPercent ?? -1) + .ThenByDescending(x => x.Severity == "Critical" ? 3 : x.Severity == "Warning" ? 2 : 1) + .ThenBy(x => x.Type)) + { +
+ @w.Severity + @if (w.Operator != null) + { + @w.Operator + } + @w.Type + @if (w.IsLegacy) + { + legacy + } + @if (w.MaxBenefitPercent.HasValue) + { + up to @(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))% benefit + } + @w.Message + @if (!string.IsNullOrEmpty(w.ActionableFix)) + { + @w.ActionableFix + } +
+ } +
+
+ +@code { + [Parameter] public StatementResult Stmt { get; set; } = default!; +} diff --git a/src/PlanViewer.Web/PlanViewFormat.cs b/src/PlanViewer.Web/PlanViewFormat.cs new file mode 100644 index 0000000..61f8ed1 --- /dev/null +++ b/src/PlanViewer.Web/PlanViewFormat.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; + +namespace PlanViewer.Web; + +/// +/// Stateless formatting and predicate helpers shared across the web plan-viewer +/// page and its child components (operator properties panel, insights, warnings). +/// Pulled out of Index.razor so the same helper isn't duplicated per component; +/// imported via @using static PlanViewer.Web.PlanViewFormat in _Imports.razor +/// so call sites read identically to when these were local methods. +/// +public static class PlanViewFormat +{ + public static string FormatKB(long kb) + { + if (kb < 1024) return $"{kb:N0} KB"; + if (kb < 1024 * 1024) return $"{kb / 1024.0:N1} MB"; + return $"{kb / (1024.0 * 1024.0):N2} GB"; + } + + public static string FormatMs(long ms) + { + if (ms < 1000) return $"{ms:N0} ms"; + return $"{ms / 1000.0:F3} s"; + } + + public static string FormatTtl(int days) => days switch + { + 1 => "1 day", + < 30 => $"{days} days", + 30 => "1 month", + 90 => "3 months", + 180 => "6 months", + 365 => "1 year", + _ => $"{days} days" + }; + + // Memory grant color tiers (#215 C1 + E8 + E9): + // > 100%: over-used grant (red). Spill in plan: orange. Otherwise: tier by utilization. + public static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + public static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + + public static List GetAllWarnings(StatementResult stmt) + { + var warnings = new List(stmt.Warnings); + if (stmt.OperatorTree != null) + CollectNodeWarningsRecursive(stmt.OperatorTree, warnings); + return warnings; + } + + public static List CollectNodeWarnings(OperatorResult node) + { + var warnings = new List(); + CollectNodeWarningsRecursive(node, warnings); + return warnings; + } + + public static void CollectNodeWarningsRecursive(OperatorResult node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectNodeWarningsRecursive(child, warnings); + } + + public static string BuildTooltip(PlanNode node) + { + var parts = new List(); + parts.Add($"{node.PhysicalOp} (Node {node.NodeId})"); + + if (!string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != node.PhysicalOp) + parts.Add($"Logical: {node.LogicalOp}"); + + if (node.HasActualStats) + { + parts.Add($"Actual rows: {node.ActualRows:N0}"); + parts.Add($"Estimated rows: {node.EstimateRows:N0}"); + if (node.ActualLogicalReads > 0) parts.Add($"Logical reads: {node.ActualLogicalReads:N0}"); + if (node.ActualPhysicalReads > 0) parts.Add($"Physical reads: {node.ActualPhysicalReads:N0}"); + } + else + { + parts.Add($"Estimated rows: {node.EstimateRows:N0}"); + } + + parts.Add($"Estimated cost: {node.EstimatedOperatorCost:N4}"); + parts.Add($"Subtree cost: {node.EstimatedTotalSubtreeCost:N4}"); + + if (!string.IsNullOrEmpty(node.ObjectName)) parts.Add($"Object: {node.FullObjectName ?? node.ObjectName}"); + if (!string.IsNullOrEmpty(node.IndexName)) parts.Add($"Index: {node.IndexName}"); + if (!string.IsNullOrEmpty(node.SeekPredicates)) parts.Add($"Seek: {node.SeekPredicates}"); + if (!string.IsNullOrEmpty(node.Predicate)) parts.Add($"Predicate: {node.Predicate}"); + if (!string.IsNullOrEmpty(node.OrderBy)) parts.Add($"Order by: {node.OrderBy}"); + if (!string.IsNullOrEmpty(node.OutputColumns)) parts.Add($"Output: {node.OutputColumns}"); + + foreach (var w in node.Warnings) + parts.Add($"[{w.Severity}] {w.WarningType}: {w.Message}"); + + return string.Join("\n", parts); + } + + public static string GetOperatorLabel(PlanNode node) + { + if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != "Parallelism") + return $"Parallelism ({node.LogicalOp})"; + return node.PhysicalOp; + } + + public static bool HasPredicates(PlanNode node) => + !string.IsNullOrEmpty(node.SeekPredicates) || + !string.IsNullOrEmpty(node.Predicate) || + !string.IsNullOrEmpty(node.HashKeysProbe) || + !string.IsNullOrEmpty(node.HashKeysBuild) || + !string.IsNullOrEmpty(node.BuildResidual) || + !string.IsNullOrEmpty(node.ProbeResidual) || + !string.IsNullOrEmpty(node.MergeResidual) || + !string.IsNullOrEmpty(node.PassThru) || + !string.IsNullOrEmpty(node.SetPredicate); + + public static bool HasOperatorDetails(PlanNode node) => + !string.IsNullOrEmpty(node.OrderBy) || + !string.IsNullOrEmpty(node.GroupBy) || + !string.IsNullOrEmpty(node.TopExpression) || + !string.IsNullOrEmpty(node.InnerSideJoinColumns) || + !string.IsNullOrEmpty(node.OuterSideJoinColumns) || + !string.IsNullOrEmpty(node.OuterReferences) || + !string.IsNullOrEmpty(node.DefinedValues) || + !string.IsNullOrEmpty(node.HashKeys) || + !string.IsNullOrEmpty(node.PartitionColumns) || + !string.IsNullOrEmpty(node.SegmentColumn) || + !string.IsNullOrEmpty(node.ConstantScanValues) || + !string.IsNullOrEmpty(node.ActionColumn) || + !string.IsNullOrEmpty(node.OriginalActionColumn) || + !string.IsNullOrEmpty(node.OffsetExpression) || + !string.IsNullOrEmpty(node.TvfParameters) || + !string.IsNullOrEmpty(node.UdxName) || + !string.IsNullOrEmpty(node.UdxUsedColumns) || + !string.IsNullOrEmpty(node.TieColumns) || + !string.IsNullOrEmpty(node.PartitioningType) || + !string.IsNullOrEmpty(node.PartitionId) || + !string.IsNullOrEmpty(node.StarJoinOperationType) || + !string.IsNullOrEmpty(node.ProbeColumn) || + node.ManyToMany || node.SortDistinct || node.BitmapCreator || + node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || + node.Remoting || node.LocalParallelism || node.StartupExpression || + node.DMLRequestSort || node.SpoolStack || node.WithTies || + node.IsStarJoin || node.InRow || node.ComputeSequence || + node.RowCount || node.GroupExecuted || node.RemoteDataAccess || + node.OptimizedHalloweenProtectionUsed || + node.NonClusteredIndexCount > 0 || node.TopRows > 0 || + node.RollupHighestLevel > 0 || node.ForceSeekColumnCount > 0 || + node.StatsCollectionId > 0; + + public static bool HasMemoryInfo(PlanNode node) => + (node.MemoryGrantKB.HasValue && node.MemoryGrantKB > 0) || + (node.DesiredMemoryKB.HasValue && node.DesiredMemoryKB > 0) || + (node.MaxUsedMemoryKB.HasValue && node.MaxUsedMemoryKB > 0) || + node.InputMemoryGrantKB > 0 || + node.OutputMemoryGrantKB > 0 || + node.UsedMemoryGrantKB > 0 || + node.MemoryFractionInput > 0 || + node.MemoryFractionOutput > 0; +} diff --git a/src/PlanViewer.Web/Program.cs b/src/PlanViewer.Web/Program.cs index 8cf816a..da87e34 100644 --- a/src/PlanViewer.Web/Program.cs +++ b/src/PlanViewer.Web/Program.cs @@ -1,9 +1,14 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using PlanViewer.Web; +using PlanViewer.Web.Services; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); +// One HttpClient shared by the share service instead of one per share/delete/load call. +builder.Services.AddScoped(_ => new HttpClient()); +builder.Services.AddScoped(); + await builder.Build().RunAsync(); diff --git a/src/PlanViewer.Web/Services/PlanShareService.cs b/src/PlanViewer.Web/Services/PlanShareService.cs new file mode 100644 index 0000000..c9c02ee --- /dev/null +++ b/src/PlanViewer.Web/Services/PlanShareService.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using PlanViewer.Core.Output; + +namespace PlanViewer.Web.Services; + +/// Result of uploading a plan analysis to the share server. +public record PlanShareResult(string? Id, string? DeleteToken); + +/// A shared plan analysis fetched back from the share server. +public record SharedPlan(AnalysisResult? Result, string? Text); + +/// +/// Thrown when the share server returns a non-success response. The message is +/// already user-facing, so callers can surface it directly. +/// +public sealed class PlanShareException : Exception +{ + public PlanShareException(string message) : base(message) { } +} + +/// +/// Talks to the public plan-share API (upload / fetch / delete). Pulled out of +/// Index.razor so the page no longer news up an per call +/// and the HTTP/JSON plumbing is testable in isolation. UI concerns (clipboard, +/// state, error display) stay in the component. +/// +public interface IPlanShareService +{ + Task ShareAsync(AnalysisResult result, string text, int ttlDays); + Task DeleteAsync(string shareId, string deleteToken); + Task LoadAsync(string id); +} + +public sealed class PlanShareService : IPlanShareService +{ + public const string ApiBase = "https://stats.erikdarling.com"; + + private readonly HttpClient _http; + + public PlanShareService(HttpClient http) => _http = http; + + public async Task ShareAsync(AnalysisResult result, string text, int ttlDays) + { + var payload = JsonSerializer.Serialize(new + { + result = result, + text = text, + ttl_days = ttlDays + }); + var content = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{ApiBase}/api/share", content); + + if (!response.IsSuccessStatusCode) + throw new PlanShareException($"Share failed: server returned {(int)response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var id = doc.RootElement.GetProperty("id").GetString(); + var deleteToken = doc.RootElement.GetProperty("delete_token").GetString(); + return new PlanShareResult(id, deleteToken); + } + + public async Task DeleteAsync(string shareId, string deleteToken) + { + var response = await _http.DeleteAsync($"{ApiBase}/api/plans/{shareId}?token={deleteToken}"); + if (!response.IsSuccessStatusCode) + throw new PlanShareException("Failed to delete shared plan."); + } + + public async Task LoadAsync(string id) + { + var response = await _http.GetAsync($"{ApiBase}/api/plans/{id}"); + if (!response.IsSuccessStatusCode) + { + throw new PlanShareException(response.StatusCode == HttpStatusCode.NotFound + ? "This shared plan has expired or does not exist." + : $"Failed to load shared plan: {(int)response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var result = JsonSerializer.Deserialize(root.GetProperty("result").GetRawText()); + var text = root.GetProperty("text").GetString(); + return new SharedPlan(result, text); + } +} diff --git a/src/PlanViewer.Web/_Imports.razor b/src/PlanViewer.Web/_Imports.razor index e40d810..89cbb8c 100644 --- a/src/PlanViewer.Web/_Imports.razor +++ b/src/PlanViewer.Web/_Imports.razor @@ -9,3 +9,5 @@ @using PlanViewer.Core.Models @using PlanViewer.Core.Services @using PlanViewer.Core.Output +@using PlanViewer.Web.Services +@using static PlanViewer.Web.PlanViewFormat From 0c4e2b3abbd2a8e94ed255158d03be52c7ab16b7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:41:56 -0400 Subject: [PATCH 06/16] Split PlanViewerControl.Properties.cs into feature partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Properties.cs was a 1772-line catch-all whose name said "Properties" but which actually built six different right-side panels. Peel the non-core panels into partials of the same class (pure mechanical move, no behavior change): - PlanViewerControl.MissingIndexes.cs — ShowMissingIndexes - PlanViewerControl.Parameters.cs — ShowParameters + param-cell/annotation builders + unresolved-variable detection - PlanViewerControl.WaitStats.cs — ShowWaitStats + wait categorization - PlanViewerControl.RuntimeSummary.cs — ShowRuntimeSummary + ShowServerContext Properties.cs keeps the General/Object/etc. ShowPropertiesPanel core plus the shared AddPropertySection/AddPropertyRow builders and close handlers, dropping from 1772 to 1091 lines. (The per-node time-attribution helpers already moved to Core's NodeTimeAttribution earlier in this branch.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PlanViewerControl.MissingIndexes.cs | 80 ++ .../Controls/PlanViewerControl.Parameters.cs | 239 ++++++ .../Controls/PlanViewerControl.Properties.cs | 681 ------------------ .../PlanViewerControl.RuntimeSummary.cs | 274 +++++++ .../Controls/PlanViewerControl.WaitStats.cs | 164 +++++ 5 files changed, 757 insertions(+), 681 deletions(-) create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.MissingIndexes.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs create mode 100644 src/PlanViewer.App/Controls/PlanViewerControl.WaitStats.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.MissingIndexes.cs b/src/PlanViewer.App/Controls/PlanViewerControl.MissingIndexes.cs new file mode 100644 index 0000000..1db7a63 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.MissingIndexes.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowMissingIndexes(List indexes) + { + MissingIndexContent.Children.Clear(); + + if (indexes.Count > 0) + { + // Update expander header with count + MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; + + // Build each missing index row manually (no ItemsControl template binding) + foreach (var mi in indexes) + { + var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; + + var headerRow = new StackPanel { Orientation = Orientation.Horizontal }; + headerRow.Children.Add(new TextBlock + { + Text = mi.Table, + FontWeight = FontWeight.SemiBold, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $" \u2014 Impact: ", + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + FontSize = 12 + }); + headerRow.Children.Add(new TextBlock + { + Text = $"{mi.Impact:F1}%", + Foreground = new SolidColorBrush(Color.Parse("#FFB347")), + FontSize = 12 + }); + itemPanel.Children.Add(headerRow); + + if (!string.IsNullOrEmpty(mi.CreateStatement)) + { + itemPanel.Children.Add(new SelectableTextBlock + { + Text = mi.CreateStatement, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + Foreground = TooltipFgBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(12, 2, 0, 0) + }); + } + + MissingIndexContent.Children.Add(itemPanel); + } + + MissingIndexEmpty.IsVisible = false; + } + else + { + MissingIndexHeader.Text = "Missing Index Suggestions"; + MissingIndexEmpty.IsVisible = true; + } + } + +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs new file mode 100644 index 0000000..126f2cb --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowParameters(PlanStatement statement) + { + ParametersContent.Children.Clear(); + ParametersEmpty.IsVisible = false; + + var parameters = statement.Parameters; + + if (parameters.Count == 0) + { + var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); + if (localVars.Count > 0) + { + ParametersHeader.Text = "Parameters"; + AddParameterAnnotation( + $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML", + "#FFB347"); + } + else + { + ParametersHeader.Text = "Parameters"; + ParametersEmpty.IsVisible = true; + } + return; + } + + ParametersHeader.Text = $"Parameters ({parameters.Count})"; + + var allCompiledNull = parameters.All(p => p.CompiledValue == null); + var hasCompiled = parameters.Any(p => p.CompiledValue != null); + var hasRuntime = parameters.Any(p => p.RuntimeValue != null); + + // Build a 4-column grid: Name | Data Type | Compiled | Runtime + // Only show Compiled/Runtime columns if at least one param has that value + var colDef = "Auto,Auto"; // Name, DataType always shown + int compiledCol = -1, runtimeCol = -1; + int nextCol = 2; + if (hasCompiled) + { + colDef += ",*"; + compiledCol = nextCol++; + } + if (hasRuntime) + { + colDef += ",*"; + runtimeCol = nextCol++; + } + // If neither compiled nor runtime, still add one value column for "?" + if (!hasCompiled && !hasRuntime) + { + colDef += ",*"; + compiledCol = nextCol++; + } + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) }; + int rowIndex = 0; + + // Header row + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold); + AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold); + if (compiledCol >= 0) + AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold); + if (runtimeCol >= 0) + AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold); + rowIndex++; + + foreach (var param in parameters) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + // Name + AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold); + + // Data type + AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB"); + + // Compiled value + if (compiledCol >= 0) + { + var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?"); + var compiledColor = param.CompiledValue != null ? "#E4E6EB" + : allCompiledNull ? "#E4E6EB" : "#E57373"; + AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor); + } + + // Runtime value — amber if it differs from compiled + if (runtimeCol >= 0) + { + var runtimeText = param.RuntimeValue ?? ""; + var sniffed = param.RuntimeValue != null + && param.CompiledValue != null + && param.RuntimeValue != param.CompiledValue; + var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB"; + var tooltip = sniffed + ? "Runtime value differs from compiled — possible parameter sniffing" + : null; + AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip); + } + + rowIndex++; + } + + ParametersContent.Children.Add(grid); + + // Annotations + if (allCompiledNull && parameters.Count > 0) + { + var hasOptimizeForUnknown = statement.StatementText + .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) + && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); + + if (hasOptimizeForUnknown) + { + AddParameterAnnotation( + "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values", + "#6BB5FF"); + } + else + { + AddParameterAnnotation( + "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", + "#FFB347"); + } + } + + var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); + if (unresolved.Count > 0) + { + AddParameterAnnotation( + $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list", + "#FFB347"); + } + } + + private static void AddParamCell(Grid grid, int row, int col, string text, string color, + FontWeight fontWeight = default, string? tooltip = null) + { + var tb = new TextBlock + { + Text = text, + FontSize = 11, + FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight, + Foreground = new SolidColorBrush(Color.Parse(color)), + Margin = new Thickness(0, 2, 10, 2), + TextTrimming = TextTrimming.CharacterEllipsis, + MaxWidth = 200 + }; + // Name and DataType columns are short — no need for max width + if (col <= 1) + tb.MaxWidth = double.PositiveInfinity; + if (tooltip != null) + ToolTip.SetTip(tb, tooltip); + else if (text.Length > 30) + ToolTip.SetTip(tb, text); + Grid.SetRow(tb, row); + Grid.SetColumn(tb, col); + grid.Children.Add(tb); + } + + private void AddParameterAnnotation(string text, string color) + { + ParametersContent.Children.Add(new TextBlock + { + Text = text, + FontSize = 11, + FontStyle = FontStyle.Italic, + Foreground = new SolidColorBrush(Color.Parse(color)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 6, 0, 0) + }); + } + + private static List FindUnresolvedVariables(string queryText, List parameters, + PlanNode? rootNode = null) + { + var unresolved = new List(); + if (string.IsNullOrEmpty(queryText)) + return unresolved; + + var extractedNames = new HashSet( + parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + + // Collect table variable names from the plan tree so we don't misreport them as local variables + var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (rootNode != null) + CollectTableVariableNames(rootNode, tableVarNames); + + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); + var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in matches) + { + var varName = match.Value; + if (seenVars.Contains(varName) || extractedNames.Contains(varName)) + continue; + if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase)) + continue; + if (tableVarNames.Contains(varName)) + continue; + + seenVars.Add(varName); + unresolved.Add(varName); + } + + return unresolved; + } + + private static void CollectTableVariableNames(PlanNode node, HashSet names) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + // ObjectName is like "@t.c" — extract the table variable name "@t" + var dotIdx = node.ObjectName.IndexOf('.'); + var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName; + names.Add(tvName); + } + foreach (var child in node.Children) + CollectTableVariableNames(child, names); + } + +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs index 35c1dfd..4978979 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs @@ -1082,691 +1082,10 @@ private void ClosePropertiesPanel() } } - private void ShowMissingIndexes(List indexes) - { - MissingIndexContent.Children.Clear(); - - if (indexes.Count > 0) - { - // Update expander header with count - MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})"; - - // Build each missing index row manually (no ItemsControl template binding) - foreach (var mi in indexes) - { - var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) }; - - var headerRow = new StackPanel { Orientation = Orientation.Horizontal }; - headerRow.Children.Add(new TextBlock - { - Text = mi.Table, - FontWeight = FontWeight.SemiBold, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $" \u2014 Impact: ", - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - FontSize = 12 - }); - headerRow.Children.Add(new TextBlock - { - Text = $"{mi.Impact:F1}%", - Foreground = new SolidColorBrush(Color.Parse("#FFB347")), - FontSize = 12 - }); - itemPanel.Children.Add(headerRow); - - if (!string.IsNullOrEmpty(mi.CreateStatement)) - { - itemPanel.Children.Add(new SelectableTextBlock - { - Text = mi.CreateStatement, - FontFamily = new FontFamily("Consolas"), - FontSize = 11, - Foreground = TooltipFgBrush, - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(12, 2, 0, 0) - }); - } - - MissingIndexContent.Children.Add(itemPanel); - } - - MissingIndexEmpty.IsVisible = false; - } - else - { - MissingIndexHeader.Text = "Missing Index Suggestions"; - MissingIndexEmpty.IsVisible = true; - } - } - - private void ShowParameters(PlanStatement statement) - { - ParametersContent.Children.Clear(); - ParametersEmpty.IsVisible = false; - - var parameters = statement.Parameters; - - if (parameters.Count == 0) - { - var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); - if (localVars.Count > 0) - { - ParametersHeader.Text = "Parameters"; - AddParameterAnnotation( - $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML", - "#FFB347"); - } - else - { - ParametersHeader.Text = "Parameters"; - ParametersEmpty.IsVisible = true; - } - return; - } - - ParametersHeader.Text = $"Parameters ({parameters.Count})"; - - var allCompiledNull = parameters.All(p => p.CompiledValue == null); - var hasCompiled = parameters.Any(p => p.CompiledValue != null); - var hasRuntime = parameters.Any(p => p.RuntimeValue != null); - - // Build a 4-column grid: Name | Data Type | Compiled | Runtime - // Only show Compiled/Runtime columns if at least one param has that value - var colDef = "Auto,Auto"; // Name, DataType always shown - int compiledCol = -1, runtimeCol = -1; - int nextCol = 2; - if (hasCompiled) - { - colDef += ",*"; - compiledCol = nextCol++; - } - if (hasRuntime) - { - colDef += ",*"; - runtimeCol = nextCol++; - } - // If neither compiled nor runtime, still add one value column for "?" - if (!hasCompiled && !hasRuntime) - { - colDef += ",*"; - compiledCol = nextCol++; - } - - var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) }; - int rowIndex = 0; - - // Header row - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold); - AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold); - if (compiledCol >= 0) - AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold); - if (runtimeCol >= 0) - AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold); - rowIndex++; - - foreach (var param in parameters) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - // Name - AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold); - - // Data type - AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB"); - - // Compiled value - if (compiledCol >= 0) - { - var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?"); - var compiledColor = param.CompiledValue != null ? "#E4E6EB" - : allCompiledNull ? "#E4E6EB" : "#E57373"; - AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor); - } - - // Runtime value — amber if it differs from compiled - if (runtimeCol >= 0) - { - var runtimeText = param.RuntimeValue ?? ""; - var sniffed = param.RuntimeValue != null - && param.CompiledValue != null - && param.RuntimeValue != param.CompiledValue; - var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB"; - var tooltip = sniffed - ? "Runtime value differs from compiled — possible parameter sniffing" - : null; - AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip); - } - - rowIndex++; - } - - ParametersContent.Children.Add(grid); - - // Annotations - if (allCompiledNull && parameters.Count > 0) - { - var hasOptimizeForUnknown = statement.StatementText - .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) - && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); - - if (hasOptimizeForUnknown) - { - AddParameterAnnotation( - "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values", - "#6BB5FF"); - } - else - { - AddParameterAnnotation( - "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed", - "#FFB347"); - } - } - - var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); - if (unresolved.Count > 0) - { - AddParameterAnnotation( - $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list", - "#FFB347"); - } - } - - private static void AddParamCell(Grid grid, int row, int col, string text, string color, - FontWeight fontWeight = default, string? tooltip = null) - { - var tb = new TextBlock - { - Text = text, - FontSize = 11, - FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight, - Foreground = new SolidColorBrush(Color.Parse(color)), - Margin = new Thickness(0, 2, 10, 2), - TextTrimming = TextTrimming.CharacterEllipsis, - MaxWidth = 200 - }; - // Name and DataType columns are short — no need for max width - if (col <= 1) - tb.MaxWidth = double.PositiveInfinity; - if (tooltip != null) - ToolTip.SetTip(tb, tooltip); - else if (text.Length > 30) - ToolTip.SetTip(tb, text); - Grid.SetRow(tb, row); - Grid.SetColumn(tb, col); - grid.Children.Add(tb); - } - - private void AddParameterAnnotation(string text, string color) - { - ParametersContent.Children.Add(new TextBlock - { - Text = text, - FontSize = 11, - FontStyle = FontStyle.Italic, - Foreground = new SolidColorBrush(Color.Parse(color)), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 6, 0, 0) - }); - } - - private static List FindUnresolvedVariables(string queryText, List parameters, - PlanNode? rootNode = null) - { - var unresolved = new List(); - if (string.IsNullOrEmpty(queryText)) - return unresolved; - - var extractedNames = new HashSet( - parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); - - // Collect table variable names from the plan tree so we don't misreport them as local variables - var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase); - if (rootNode != null) - CollectTableVariableNames(rootNode, tableVarNames); - - var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); - var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (Match match in matches) - { - var varName = match.Value; - if (seenVars.Contains(varName) || extractedNames.Contains(varName)) - continue; - if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase)) - continue; - if (tableVarNames.Contains(varName)) - continue; - - seenVars.Add(varName); - unresolved.Add(varName); - } - - return unresolved; - } - - private static void CollectTableVariableNames(PlanNode node, HashSet names) - { - if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) - { - // ObjectName is like "@t.c" — extract the table variable name "@t" - var dotIdx = node.ObjectName.IndexOf('.'); - var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName; - names.Add(tvName); - } - foreach (var child in node.Children) - CollectTableVariableNames(child, names); - } - - private void ShowWaitStats(List waits, List benefits, bool isActualPlan) - { - WaitStatsContent.Children.Clear(); - - if (waits.Count == 0) - { - WaitStatsHeader.Text = "Wait Stats"; - WaitStatsEmpty.Text = isActualPlan - ? "No wait stats recorded" - : "No wait stats (estimated plan)"; - WaitStatsEmpty.IsVisible = true; - return; - } - - WaitStatsEmpty.IsVisible = false; - - // Build benefit lookup - var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var wb in benefits) - benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; - - var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); - var maxWait = sorted[0].WaitTimeMs; - var totalWait = sorted.Sum(w => w.WaitTimeMs); - - // Update expander header with total - WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; - - // Build a single Grid for all rows so columns align - // Name, bar, duration, and benefit columns - var grid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") - }; - for (int i = 0; i < sorted.Count; i++) - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - for (int i = 0; i < sorted.Count; i++) - { - var w = sorted[i]; - var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0; - var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType)); - - // Wait type name — colored by category - var nameText = new TextBlock - { - Text = w.WaitType, - FontSize = 12, - Foreground = new SolidColorBrush(Color.Parse(color)), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 10, 2) - }; - Grid.SetRow(nameText, i); - Grid.SetColumn(nameText, 0); - grid.Children.Add(nameText); - - // Bar — semi-transparent category color, compact proportional indicator - var barColor = Color.Parse(color); - var colorBar = new Border - { - Width = Math.Max(4, barFraction * 60), - Height = 14, - Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)), - CornerRadius = new CornerRadius(2), - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 8, 2) - }; - Grid.SetRow(colorBar, i); - Grid.SetColumn(colorBar, 1); - grid.Children.Add(colorBar); - - // Duration text - var durationText = new TextBlock - { - Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", - FontSize = 12, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 8, 2) - }; - Grid.SetRow(durationText, i); - Grid.SetColumn(durationText, 2); - grid.Children.Add(durationText); - - // Benefit % (if available) - if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) - { - var benefitText = new TextBlock - { - Text = $"up to {benefitPct:N0}%", - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse("#8b949e")), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 2, 0, 2) - }; - Grid.SetRow(benefitText, i); - Grid.SetColumn(benefitText, 3); - grid.Children.Add(benefitText); - } - } - - WaitStatsContent.Children.Add(grid); - - } - - private void ShowRuntimeSummary(PlanStatement statement) - { - RuntimeSummaryContent.Children.Clear(); - - var labelColor = "#E4E6EB"; - var valueColor = "#E4E6EB"; - - var grid = new Grid - { - ColumnDefinitions = new ColumnDefinitions("Auto,*") - }; - int rowIndex = 0; - - void AddRow(string label, string value, string? color = null) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - - var labelText = new TextBlock - { - Text = label, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(labelColor)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) - }; - Grid.SetRow(labelText, rowIndex); - Grid.SetColumn(labelText, 0); - grid.Children.Add(labelText); - - var valueText = new TextBlock - { - Text = value, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)), - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(valueText, rowIndex); - Grid.SetColumn(valueText, 1); - grid.Children.Add(valueText); - - rowIndex++; - } - - // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%. - // Loosened per Joe's feedback (#215 C1): for memory grants, moderate - // utilization (e.g. 60%) is fine — operators can spill near their max, - // so we shouldn't flag anything above a real over-grant threshold. - static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" - : pct >= 20 ? "#FFB347" : "#E57373"; - - // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), - // any operator spilled (orange), otherwise tier by utilization. - static string MemoryGrantColor(double pctUsed, bool hasSpill) - { - if (pctUsed > 100) return "#E57373"; - if (hasSpill) return "#FFB347"; - if (pctUsed >= 40) return "#E4E6EB"; - if (pctUsed >= 20) return "#FFB347"; - return "#E57373"; - } - - // E7: rename the panel title for estimated plans - var isEstimated = statement.QueryTimeStats == null; - RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; - - var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); - - // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. - // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. - - if (statement.QueryTimeStats != null) - { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - if (statement.QueryTimeStats.ElapsedTimeMs > 0) - { - long externalWaitMs = 0; - foreach (var w in statement.WaitStats) - if (BenefitScorer.IsExternalWait(w.WaitType)) - externalWaitMs += w.WaitTimeMs; - var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); - var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; - AddRow("CPU:Elapsed", ratio.ToString("N2")); - } - } - - // DOP + parallelism efficiency - if (statement.DegreeOfParallelism > 0) - { - var dopText = statement.DegreeOfParallelism.ToString(); - string? dopColor = null; - if (statement.QueryTimeStats != null && - statement.QueryTimeStats.ElapsedTimeMs > 0 && - statement.QueryTimeStats.CpuTimeMs > 0 && - statement.DegreeOfParallelism > 1) - { - long externalWaitMs = 0; - foreach (var w in statement.WaitStats) - if (BenefitScorer.IsExternalWait(w.WaitType)) - externalWaitMs += w.WaitTimeMs; - var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); - var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; - var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0); - efficiency = Math.Max(0.0, efficiency); - dopText += $" ({efficiency:N0}% efficient)"; - dopColor = EfficiencyColor(efficiency); - } - AddRow("DOP", dopText, dopColor); - } - else if (statement.NonParallelPlanReason != null) - AddRow("Serial", statement.NonParallelPlanReason); - - if (statement.QueryTimeStats != null) - { - AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); - if (statement.QueryUdfCpuTimeMs > 0) - AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); - if (statement.QueryUdfElapsedTimeMs > 0) - AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); - } - - // Compile stats (category B plan-level property) - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Memory grant — color per new tiers, spill indicator if any operator spilled - if (statement.MemoryGrant != null) - { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); - var spillTag = hasSpillInTree ? " ⚠ spill" : ""; - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); - } - - // Thread stats - if (statement.ThreadStats != null) - { - var ts = statement.ThreadStats; - AddRow("Branches", ts.Branches.ToString()); - var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads); - if (totalReserved > 0) - { - var threadPct = (double)ts.UsedThreads / totalReserved * 100; - var threadColor = EfficiencyColor(threadPct); - var threadText = ts.UsedThreads == totalReserved - ? $"{ts.UsedThreads} used ({totalReserved} reserved)" - : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; - AddRow("Threads", threadText, threadColor); - } - else - { - AddRow("Threads", $"{ts.UsedThreads} used"); - } - } - - // Optimization + CE model - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - if (grid.Children.Count > 0) - { - RuntimeSummaryContent.Children.Add(grid); - RuntimeSummaryEmpty.IsVisible = false; - } - else - { - RuntimeSummaryEmpty.IsVisible = true; - } - ShowServerContext(); - } - - private void ShowServerContext() - { - ServerContextContent.Children.Clear(); - if (_serverMetadata == null) - { - ServerContextEmpty.IsVisible = true; - ServerContextBorder.IsVisible = true; - return; - } - - ServerContextEmpty.IsVisible = false; - - var m = _serverMetadata; - var fgColor = "#E4E6EB"; - - var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; - int rowIndex = 0; - - void AddRow(string label, string value) - { - grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - var lb = new TextBlock - { - Text = label, FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(fgColor)), - HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) - }; - Grid.SetRow(lb, rowIndex); - Grid.SetColumn(lb, 0); - grid.Children.Add(lb); - - var vb = new TextBlock - { - Text = value, FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse(fgColor)), - Margin = new Thickness(0, 1, 0, 1) - }; - Grid.SetRow(vb, rowIndex); - Grid.SetColumn(vb, 1); - grid.Children.Add(vb); - rowIndex++; - } - - // Server name + edition - var edition = m.Edition; - if (edition != null) - { - var idx = edition.IndexOf(" (64-bit)"); - if (idx > 0) edition = edition[..idx]; - } - var serverLine = m.ServerName ?? "Unknown"; - if (edition != null) serverLine += $" ({edition})"; - if (m.ProductVersion != null) serverLine += $", {m.ProductVersion}"; - AddRow("Server", serverLine); - - // Hardware - if (m.CpuCount > 0) - AddRow("Hardware", $"{m.CpuCount} CPUs, {m.PhysicalMemoryMB:N0} MB RAM"); - - // Instance settings - AddRow("MAXDOP", m.MaxDop.ToString()); - AddRow("Cost threshold", m.CostThresholdForParallelism.ToString()); - AddRow("Max memory", $"{m.MaxServerMemoryMB:N0} MB"); - - // Database - if (m.Database != null) - AddRow("Database", $"{m.Database.Name} (compat {m.Database.CompatibilityLevel})"); - - ServerContextContent.Children.Add(grid); - ServerContextBorder.IsVisible = true; - } - private void UpdateInsightsHeader() { InsightsPanel.IsVisible = true; InsightsHeader.Text = " Plan Insights"; } - private static string GetWaitCategory(string waitType) - { - if (waitType.StartsWith("SOS_SCHEDULER_YIELD") || - waitType.StartsWith("CXPACKET") || - waitType.StartsWith("CXCONSUMER") || - waitType.StartsWith("CXSYNC_PORT") || - waitType.StartsWith("CXSYNC_CONSUMER")) - return "CPU"; - - if (waitType.StartsWith("PAGEIOLATCH") || - waitType.StartsWith("WRITELOG") || - waitType.StartsWith("IO_COMPLETION") || - waitType.StartsWith("ASYNC_IO_COMPLETION")) - return "I/O"; - - if (waitType.StartsWith("LCK_M_")) - return "Lock"; - - if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD") - return "Memory"; - - if (waitType == "ASYNC_NETWORK_IO") - return "Network"; - - return "Other"; - } - - private static string GetWaitCategoryColor(string category) - { - return category switch - { - "CPU" => "#4FA3FF", - "I/O" => "#FFB347", - "Lock" => "#E57373", - "Memory" => "#9B59B6", - "Network" => "#2ECC71", - _ => "#6BB5FF" - }; - } } diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs new file mode 100644 index 0000000..1a575d7 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowRuntimeSummary(PlanStatement statement) + { + RuntimeSummaryContent.Children.Clear(); + + var labelColor = "#E4E6EB"; + var valueColor = "#E4E6EB"; + + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*") + }; + int rowIndex = 0; + + void AddRow(string label, string value, string? color = null) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + var labelText = new TextBlock + { + Text = label, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(labelColor)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 8, 1) + }; + Grid.SetRow(labelText, rowIndex); + Grid.SetColumn(labelText, 0); + grid.Children.Add(labelText); + + var valueText = new TextBlock + { + Text = value, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)), + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(valueText, rowIndex); + Grid.SetColumn(valueText, 1); + grid.Children.Add(valueText); + + rowIndex++; + } + + // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%. + // Loosened per Joe's feedback (#215 C1): for memory grants, moderate + // utilization (e.g. 60%) is fine — operators can spill near their max, + // so we shouldn't flag anything above a real over-grant threshold. + static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" + : pct >= 20 ? "#FFB347" : "#E57373"; + + // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), + // any operator spilled (orange), otherwise tier by utilization. + static string MemoryGrantColor(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "#E57373"; + if (hasSpill) return "#FFB347"; + if (pctUsed >= 40) return "#E4E6EB"; + if (pctUsed >= 20) return "#FFB347"; + return "#E57373"; + } + + // E7: rename the panel title for estimated plans + var isEstimated = statement.QueryTimeStats == null; + RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; + + var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); + + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. + + if (statement.QueryTimeStats != null) + { + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + if (statement.QueryTimeStats.ElapsedTimeMs > 0) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + AddRow("CPU:Elapsed", ratio.ToString("N2")); + } + } + + // DOP + parallelism efficiency + if (statement.DegreeOfParallelism > 0) + { + var dopText = statement.DegreeOfParallelism.ToString(); + string? dopColor = null; + if (statement.QueryTimeStats != null && + statement.QueryTimeStats.ElapsedTimeMs > 0 && + statement.QueryTimeStats.CpuTimeMs > 0 && + statement.DegreeOfParallelism > 1) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0); + efficiency = Math.Max(0.0, efficiency); + dopText += $" ({efficiency:N0}% efficient)"; + dopColor = EfficiencyColor(efficiency); + } + AddRow("DOP", dopText, dopColor); + } + else if (statement.NonParallelPlanReason != null) + AddRow("Serial", statement.NonParallelPlanReason); + + if (statement.QueryTimeStats != null) + { + AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); + if (statement.QueryUdfCpuTimeMs > 0) + AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); + if (statement.QueryUdfElapsedTimeMs > 0) + AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + } + + // Compile stats (category B plan-level property) + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Memory grant — color per new tiers, spill indicator if any operator spilled + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); + var spillTag = hasSpillInTree ? " ⚠ spill" : ""; + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // Thread stats + if (statement.ThreadStats != null) + { + var ts = statement.ThreadStats; + AddRow("Branches", ts.Branches.ToString()); + var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads); + if (totalReserved > 0) + { + var threadPct = (double)ts.UsedThreads / totalReserved * 100; + var threadColor = EfficiencyColor(threadPct); + var threadText = ts.UsedThreads == totalReserved + ? $"{ts.UsedThreads} used ({totalReserved} reserved)" + : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)"; + AddRow("Threads", threadText, threadColor); + } + else + { + AddRow("Threads", $"{ts.UsedThreads} used"); + } + } + + // Optimization + CE model + if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) + AddRow("Optimization", statement.StatementOptmLevel); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + + if (grid.Children.Count > 0) + { + RuntimeSummaryContent.Children.Add(grid); + RuntimeSummaryEmpty.IsVisible = false; + } + else + { + RuntimeSummaryEmpty.IsVisible = true; + } + ShowServerContext(); + } + + private void ShowServerContext() + { + ServerContextContent.Children.Clear(); + if (_serverMetadata == null) + { + ServerContextEmpty.IsVisible = true; + ServerContextBorder.IsVisible = true; + return; + } + + ServerContextEmpty.IsVisible = false; + + var m = _serverMetadata; + var fgColor = "#E4E6EB"; + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; + int rowIndex = 0; + + void AddRow(string label, string value) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + var lb = new TextBlock + { + Text = label, FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(fgColor)), + HorizontalAlignment = HorizontalAlignment.Left, + Margin = new Thickness(0, 1, 8, 1) + }; + Grid.SetRow(lb, rowIndex); + Grid.SetColumn(lb, 0); + grid.Children.Add(lb); + + var vb = new TextBlock + { + Text = value, FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse(fgColor)), + Margin = new Thickness(0, 1, 0, 1) + }; + Grid.SetRow(vb, rowIndex); + Grid.SetColumn(vb, 1); + grid.Children.Add(vb); + rowIndex++; + } + + // Server name + edition + var edition = m.Edition; + if (edition != null) + { + var idx = edition.IndexOf(" (64-bit)"); + if (idx > 0) edition = edition[..idx]; + } + var serverLine = m.ServerName ?? "Unknown"; + if (edition != null) serverLine += $" ({edition})"; + if (m.ProductVersion != null) serverLine += $", {m.ProductVersion}"; + AddRow("Server", serverLine); + + // Hardware + if (m.CpuCount > 0) + AddRow("Hardware", $"{m.CpuCount} CPUs, {m.PhysicalMemoryMB:N0} MB RAM"); + + // Instance settings + AddRow("MAXDOP", m.MaxDop.ToString()); + AddRow("Cost threshold", m.CostThresholdForParallelism.ToString()); + AddRow("Max memory", $"{m.MaxServerMemoryMB:N0} MB"); + + // Database + if (m.Database != null) + AddRow("Database", $"{m.Database.Name} (compat {m.Database.CompatibilityLevel})"); + + ServerContextContent.Children.Add(grid); + ServerContextBorder.IsVisible = true; + } + +} diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.WaitStats.cs b/src/PlanViewer.App/Controls/PlanViewerControl.WaitStats.cs new file mode 100644 index 0000000..e298659 --- /dev/null +++ b/src/PlanViewer.App/Controls/PlanViewerControl.WaitStats.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class PlanViewerControl : UserControl +{ + private void ShowWaitStats(List waits, List benefits, bool isActualPlan) + { + WaitStatsContent.Children.Clear(); + + if (waits.Count == 0) + { + WaitStatsHeader.Text = "Wait Stats"; + WaitStatsEmpty.Text = isActualPlan + ? "No wait stats recorded" + : "No wait stats (estimated plan)"; + WaitStatsEmpty.IsVisible = true; + return; + } + + WaitStatsEmpty.IsVisible = false; + + // Build benefit lookup + var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var wb in benefits) + benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + + var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList(); + var maxWait = sorted[0].WaitTimeMs; + var totalWait = sorted.Sum(w => w.WaitTimeMs); + + // Update expander header with total + WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total"; + + // Build a single Grid for all rows so columns align + // Name, bar, duration, and benefit columns + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto") + }; + for (int i = 0; i < sorted.Count; i++) + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + + for (int i = 0; i < sorted.Count; i++) + { + var w = sorted[i]; + var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0; + var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType)); + + // Wait type name — colored by category + var nameText = new TextBlock + { + Text = w.WaitType, + FontSize = 12, + Foreground = new SolidColorBrush(Color.Parse(color)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 10, 2) + }; + Grid.SetRow(nameText, i); + Grid.SetColumn(nameText, 0); + grid.Children.Add(nameText); + + // Bar — semi-transparent category color, compact proportional indicator + var barColor = Color.Parse(color); + var colorBar = new Border + { + Width = Math.Max(4, barFraction * 60), + Height = 14, + Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)), + CornerRadius = new CornerRadius(2), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + Grid.SetRow(colorBar, i); + Grid.SetColumn(colorBar, 1); + grid.Children.Add(colorBar); + + // Duration text + var durationText = new TextBlock + { + Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", + FontSize = 12, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 8, 2) + }; + Grid.SetRow(durationText, i); + Grid.SetColumn(durationText, 2); + grid.Children.Add(durationText); + + // Benefit % (if available) + if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0) + { + var benefitText = new TextBlock + { + Text = $"up to {benefitPct:N0}%", + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#8b949e")), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 2, 0, 2) + }; + Grid.SetRow(benefitText, i); + Grid.SetColumn(benefitText, 3); + grid.Children.Add(benefitText); + } + } + + WaitStatsContent.Children.Add(grid); + + } + + private static string GetWaitCategory(string waitType) + { + if (waitType.StartsWith("SOS_SCHEDULER_YIELD") || + waitType.StartsWith("CXPACKET") || + waitType.StartsWith("CXCONSUMER") || + waitType.StartsWith("CXSYNC_PORT") || + waitType.StartsWith("CXSYNC_CONSUMER")) + return "CPU"; + + if (waitType.StartsWith("PAGEIOLATCH") || + waitType.StartsWith("WRITELOG") || + waitType.StartsWith("IO_COMPLETION") || + waitType.StartsWith("ASYNC_IO_COMPLETION")) + return "I/O"; + + if (waitType.StartsWith("LCK_M_")) + return "Lock"; + + if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD") + return "Memory"; + + if (waitType == "ASYNC_NETWORK_IO") + return "Network"; + + return "Other"; + } + + private static string GetWaitCategoryColor(string category) + { + return category switch + { + "CPU" => "#4FA3FF", + "I/O" => "#FFB347", + "Lock" => "#E57373", + "Memory" => "#9B59B6", + "Network" => "#2ECC71", + _ => "#6BB5FF" + }; + } +} From 01464fb6bf3856f2b20640f8f110ff04b77477d5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:48:00 -0400 Subject: [PATCH 07/16] Split QueryStore History + Overview controls into partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both controls were unsplit god files — QueryStoreHistoryControl in particular was the team's outlier, while every comparable sibling control already uses feature partials. Bring them in line (pure mechanical moves, no behavior change): QueryStoreHistoryControl.axaml.cs (1104 -> 321): .Fetch.cs — LoadHistoryAsync .Grid.cs — color-indicator plumbing + grid<->chart selection sync .Legend.cs — legend panel + plan highlighting .Chart.cs — chart drawing, smart X axis, dot highlighting .Selection.cs — pointer/box selection + hover tooltip .PlanLoad.cs — context menu + load-plan-from-selection QueryStoreOverviewControl.axaml.cs (823 -> 250): .Donut.cs — donut/arc geometry + popup + legend .WaitStatsChart.cs — stacked wait-stats chart .BarCards.cs — metric bar cards (+ shared _dbColorMap) Section divider comments travel with their sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../QueryStoreHistoryControl.Chart.cs | 178 ++++ .../QueryStoreHistoryControl.Fetch.cs | 86 ++ .../Controls/QueryStoreHistoryControl.Grid.cs | 158 ++++ .../QueryStoreHistoryControl.Legend.cs | 135 +++ .../QueryStoreHistoryControl.PlanLoad.cs | 100 +++ .../QueryStoreHistoryControl.Selection.cs | 246 ++++++ .../QueryStoreHistoryControl.axaml.cs | 783 ------------------ .../QueryStoreOverviewControl.BarCards.cs | 191 +++++ .../QueryStoreOverviewControl.Donut.cs | 235 ++++++ ...ueryStoreOverviewControl.WaitStatsChart.cs | 219 +++++ .../QueryStoreOverviewControl.axaml.cs | 573 ------------- 11 files changed, 1548 insertions(+), 1356 deletions(-) create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.Grid.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.Legend.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.PlanLoad.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreOverviewControl.BarCards.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreOverviewControl.Donut.cs create mode 100644 src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs new file mode 100644 index 0000000..1c043a1 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + // ── Chart ──────────────────────────────────────────────────────────── + + private void UpdateChart() + { + HistoryChart.Plot.Clear(); + _scatters.Clear(); + _selectionRect = null; + _highlightMarkers.Clear(); + _avgLine = null; + _highlightedPlanHash = null; + + if (_historyData.Count == 0) + { + HistoryChart.Refresh(); + return; + } + + var selected = MetricSelector.SelectedItem as ComboBoxItem; + var tag = selected?.Tag?.ToString() ?? "AvgCpuMs"; + var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; + + var planGroups = _historyData + .GroupBy(r => r.QueryPlanHash) + .OrderBy(g => g.Key) + .ToList(); + + foreach (var group in planGroups) + { + var planHash = group.Key; + var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); + + var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList(); + var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); + var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray(); + + var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); + scatter.Color = color.WithAlpha(140); + scatter.LegendText = ""; + scatter.LineWidth = 2; + scatter.MarkerSize = 8; + scatter.MarkerShape = MarkerShape.FilledCircle; + scatter.MarkerLineColor = ScottPlot.Color.FromHex("#AAAAAA"); + scatter.MarkerLineWidth = 1f; + + _scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash)); + } + + var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray(); + if (allValues.Length > 0) + { + var avg = allValues.Average(); + _avgLine = HistoryChart.Plot.Add.HorizontalLine(avg); + _avgLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150); + _avgLine.LineWidth = 2f; + _avgLine.LinePattern = LinePattern.DenselyDashed; + _avgLine.Text = $"avg: {avg:N0}"; + _avgLine.LabelFontColor = ScottPlot.Color.FromHex("#E4E6EB"); + _avgLine.LabelFontSize = 11; + _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(170); + _avgLine.LabelOppositeAxis = false; + _avgLine.LabelRotation = 0; + _avgLine.LabelAlignment = Alignment.LowerLeft; + _avgLine.LabelOffsetX = 38; + _avgLine.LabelOffsetY = -8; + } + + HistoryChart.Plot.Axes.AutoScale(); + var yLimits = HistoryChart.Plot.Axes.GetLimits(); + HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1); + + HistoryChart.Plot.HideLegend(); + + ConfigureSmartXAxis(); + + HistoryChart.Plot.YLabel(label); + ApplyDarkTheme(); + HistoryChart.Refresh(); + } + + private void ConfigureSmartXAxis() + { + if (_historyData.Count == 0) return; + + var minTime = _historyData.Min(r => r.IntervalStartUtc); + var maxTime = _historyData.Max(r => r.IntervalStartUtc); + var span = maxTime - minTime; + + HistoryChart.Plot.Axes.DateTimeTicksBottom(); + + if (span.TotalHours <= 48) + { + HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#E4E6EB"); + HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic + { + LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd") + }; + } + else if (span.TotalDays <= 14) + { + HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic + { + LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd") + }; + } + else + { + HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic + { + LabelFormatter = dt => dt.ToString("MM/dd\nyyyy") + }; + } + } + + // ── Dot highlighting on chart ──────────────────────────────────────── + + private void ClearHighlightMarkers() + { + foreach (var m in _highlightMarkers) + HistoryChart.Plot.Remove(m); + _highlightMarkers.Clear(); + } + + private void HighlightDotsOnChart(HashSet rowIndices) + { + ClearHighlightMarkers(); + if (rowIndices.Count == 0) + { + HistoryChart.Refresh(); + return; + } + + var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; + + var groups = rowIndices + .Where(i => i >= 0 && i < _historyData.Count) + .Select(i => _historyData[i]) + .GroupBy(r => r.QueryPlanHash); + + foreach (var group in groups) + { + var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]); + var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); + var ys = group.Select(r => GetMetricValue(r, tag)).ToArray(); + + var highlight = HistoryChart.Plot.Add.Scatter(xs, ys); + highlight.LineWidth = 0; + highlight.MarkerSize = 14; + highlight.MarkerShape = MarkerShape.FilledCircle; + highlight.Color = color; + highlight.MarkerLineColor = ScottPlot.Colors.White; + highlight.MarkerLineWidth = 2.5f; + + _highlightMarkers.Add(highlight); + } + + HistoryChart.Refresh(); + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs new file mode 100644 index 0000000..b88d110 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + private async System.Threading.Tasks.Task LoadHistoryAsync() + { + _fetchCts?.Cancel(); + _fetchCts?.Dispose(); + _fetchCts = new CancellationTokenSource(); + var ct = _fetchCts.Token; + + StatusText.Text = "Loading..."; + LoadingPanel.IsVisible = true; + + try + { + if (_useFullHistory) + { + _historyData = await QueryStoreService.FetchAggregateHistoryAsync( + _connectionString, _queryHash, _maxHoursBack, ct); + } + else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue) + { + _historyData = await QueryStoreService.FetchAggregateHistoryAsync( + _connectionString, _queryHash, ct: ct, + startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value); + } + else + { + _historyData = await QueryStoreService.FetchAggregateHistoryAsync( + _connectionString, _queryHash, _maxHoursBack, ct); + } + + BuildColorMap(); + HistoryDataGrid.ItemsSource = _historyData; + ApplyColorIndicators(); + + if (_historyData.Count > 0) + { + var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count(); + var totalExec = _historyData.Sum(r => r.CountExecutions); + var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc)); + var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc)); + StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " + + $"{totalExec:N0} total executions | " + + $"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}"; + _dataSummaryText = StatusText.Text; + } + else + { + StatusText.Text = "No history data found for this query."; + } + + UpdateChart(); + PopulateLegendPanel(); + } + catch (OperationCanceledException) + { + StatusText.Text = "Cancelled."; + } + catch (Exception ex) + { + StatusText.Text = ex.Message; + } + finally + { + LoadingPanel.IsVisible = false; + } + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Grid.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Grid.cs new file mode 100644 index 0000000..5ea0375 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Grid.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + private void BuildColorMap() + { + _planHashColorMap.Clear(); + var maxPlans = Services.AppSettingsService.Load().QueryHistoryMaxPlans; + var hashes = _historyData + .GroupBy(r => r.QueryPlanHash) + .OrderByDescending(g => g.Sum(r => r.CountExecutions)) + .Take(maxPlans) + .Select(g => g.Key) + .OrderBy(h => h) + .ToList(); + for (int i = 0; i < hashes.Count; i++) + _planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length]; + } + + private void ApplyColorIndicators() + { + HistoryDataGrid.LoadingRow -= OnDataGridLoadingRow; + HistoryDataGrid.LoadingRow += OnDataGridLoadingRow; + } + + private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e) + { + if (e.Row.DataContext is QueryStoreHistoryRow row && + _planHashColorMap.TryGetValue(row.QueryPlanHash, out var color)) + { + var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B); + var brush = new SolidColorBrush(avColor); + e.Row.Tag = brush; + + if (TryApplyColorIndicator(e.Row, brush)) + return; + } + + e.Row.Loaded -= OnRowLoaded; + e.Row.Loaded += OnRowLoaded; + } + + private void OnRowLoaded(object? sender, RoutedEventArgs e) + { + if (sender is not DataGridRow dgRow) return; + dgRow.Loaded -= OnRowLoaded; + + if (dgRow.Tag is SolidColorBrush brush) + TryApplyColorIndicator(dgRow, brush); + } + + private bool TryApplyColorIndicator(DataGridRow dgRow, SolidColorBrush brush) + { + var presenter = FindVisualChild(dgRow); + if (presenter == null) return false; + + var cell = presenter.Children.OfType().FirstOrDefault(); + if (cell == null) return false; + + var border = FindVisualChild(cell, "ColorIndicator"); + if (border == null) return false; + + border.Background = brush; + return true; + } + + private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual + { + if (parent is T t && (name == null || (t is Control c && c.Name == name))) + return t; + + var children = parent.GetVisualChildren(); + foreach (var child in children) + { + if (child is Avalonia.Visual vc) + { + var found = FindVisualChild(vc, name); + if (found != null) return found; + } + } + return null; + } + + private void HighlightGridRows() + { + // Update row backgrounds directly without resetting ItemsSource + // (which would wipe sort state and scroll position) + foreach (var row in HistoryDataGrid.GetVisualDescendants().OfType()) + { + var idx = row.Index; + row.Background = _selectedRowIndices.Contains(idx) + ? new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247)) + : Brushes.Transparent; + } + + // Also keep the LoadingRow handler for rows that get virtualized in/out + HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow; + HistoryDataGrid.LoadingRow += OnHighlightLoadingRow; + + // Scroll to first selected row + if (_selectedRowIndices.Count > 0) + { + var firstIdx = _selectedRowIndices.Min(); + if (firstIdx < _historyData.Count) + HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null); + } + } + + private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e) + { + var idx = e.Row.Index; + if (_selectedRowIndices.Contains(idx)) + { + e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247)); + } + else + { + e.Row.Background = Brushes.Transparent; + } + } + + // ── Grid row click → chart highlight ───────────────────────────────── + + private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _selectedRowIndices.Clear(); + if (HistoryDataGrid.SelectedItems != null) + { + foreach (var item in HistoryDataGrid.SelectedItems) + { + if (item is QueryStoreHistoryRow row) + { + var idx = _historyData.IndexOf(row); // O(n) but list is small (<500 items) + if (idx >= 0) + _selectedRowIndices.Add(idx); + } + } + } + + HighlightDotsOnChart(_selectedRowIndices); + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Legend.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Legend.cs new file mode 100644 index 0000000..1bde637 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Legend.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + // ── Legend ──────────────────────────────────────────────────────────── + + private void PopulateLegendPanel() + { + LegendItemsPanel.Children.Clear(); + foreach (var (hash, color) in _planHashColorMap.OrderBy(kv => kv.Key)) + { + var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B); + var item = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Spacing = 6, + Tag = hash, + Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) + }; + item.Children.Add(new Border + { + Width = 12, Height = 12, + CornerRadius = new CornerRadius(2), + Background = new SolidColorBrush(avColor), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center + }); + item.Children.Add(new TextBlock + { + Text = hash, + FontSize = 11, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0)) + }); + item.PointerPressed += OnLegendItemClicked; + LegendItemsPanel.Children.Add(item); + } + } + + private void OnLegendItemClicked(object? sender, PointerPressedEventArgs e) + { + if (sender is not StackPanel panel || panel.Tag is not string planHash) return; + + if (_highlightedPlanHash == planHash) + _highlightedPlanHash = null; + else + _highlightedPlanHash = planHash; + + ApplyPlanHighlight(); + UpdateLegendVisuals(); + } + + private void UpdateLegendVisuals() + { + foreach (var child in LegendItemsPanel.Children) + { + if (child is not StackPanel panel || panel.Tag is not string hash) continue; + var isActive = _highlightedPlanHash == null || _highlightedPlanHash == hash; + panel.Opacity = isActive ? 1.0 : 0.4; + } + } + + private void ApplyPlanHighlight() + { + var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; + + foreach (var (scatter, _, planHash) in _scatters) + { + if (_highlightedPlanHash == null) + { + var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); + scatter.Color = color.WithAlpha(140); + scatter.LineWidth = 2; + scatter.MarkerSize = 8; + } + else if (planHash == _highlightedPlanHash) + { + var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); + scatter.Color = color.WithAlpha(220); + scatter.LineWidth = 4; + scatter.MarkerSize = 10; + } + else + { + var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); + scatter.Color = color.WithAlpha(40); + scatter.LineWidth = 1; + scatter.MarkerSize = 5; + } + } + + if (_avgLine != null) + { + var relevantRows = _highlightedPlanHash != null + ? _historyData.Where(r => r.QueryPlanHash == _highlightedPlanHash).ToList() + : _historyData; + + if (relevantRows.Count > 0) + { + var avg = relevantRows.Select(r => GetMetricValue(r, tag)).Average(); + _avgLine.Y = avg; + _avgLine.Text = $"avg: {avg:N0}"; + _avgLine.IsVisible = true; + } + else + { + _avgLine.IsVisible = false; + } + } + + HistoryChart.Refresh(); + } + + private void LegendToggle_Click(object? sender, RoutedEventArgs e) + { + _legendExpanded = !_legendExpanded; + LegendPanel.IsVisible = _legendExpanded; + LegendArrow.Text = _legendExpanded ? "\u25b2" : "\u25bc"; + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.PlanLoad.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.PlanLoad.cs new file mode 100644 index 0000000..6da0c64 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.PlanLoad.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + /// + /// Returns the plan hash of the currently selected row(s), or null if none. + /// + private string? GetSelectedPlanHash() + { + // From grid selection + if (HistoryDataGrid.SelectedItem is QueryStoreHistoryRow row) + return row.QueryPlanHash; + + // From chart selection + if (_selectedRowIndices.Count > 0) + { + var idx = _selectedRowIndices.First(); + if (idx >= 0 && idx < _historyData.Count) + return _historyData[idx].QueryPlanHash; + } + + return null; + } + + private ContextMenu CreatePlanContextMenu() + { + var loadFirstItem = new MenuItem { Header = "Load Oldest Plan for This Hash" }; + var loadLastItem = new MenuItem { Header = "Load Newest Plan for This Hash" }; + + loadFirstItem.Click += (_, _) => LoadPlanFromSelection(oldest: true); + loadLastItem.Click += (_, _) => LoadPlanFromSelection(oldest: false); + + var menu = new ContextMenu + { + Items = { loadFirstItem, loadLastItem } + }; + + menu.Opening += (_, _) => + { + var hasSelection = GetSelectedPlanHash() != null; + foreach (var item in menu.Items.OfType()) + item.IsEnabled = hasSelection; + }; + + return menu; + } + + private void BuildContextMenu() + { + HistoryDataGrid.ContextMenu = CreatePlanContextMenu(); + HistoryChart.ContextMenu = CreatePlanContextMenu(); + } + + private async void LoadPlanFromSelection(bool oldest) + { + if (_isLoadingPlan) return; + var planHash = GetSelectedPlanHash(); + if (string.IsNullOrEmpty(planHash)) return; + + _isLoadingPlan = true; + StatusText.Text = "Loading plan…"; + try + { + var plan = await QueryStoreService.FetchPlanByHashAsync( + _connectionString, planHash, oldest); + + if (plan == null || string.IsNullOrEmpty(plan.PlanXml)) + { + StatusText.Text = "Plan not found"; + return; + } + + StatusText.Text = _dataSummaryText; + PlanLoadRequested?.Invoke(this, new HistoryPlanLoadEventArgs(plan)); + } + catch (Exception ex) + { + StatusText.Text = $"Error loading plan: {ex.Message}"; + } + finally + { + _isLoadingPlan = false; + } + } +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs new file mode 100644 index 0000000..7e2bc7f --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; +using ScottPlot; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreHistoryControl : UserControl +{ + // ── Box selection ──────────────────────────────────────────────────── + + private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(HistoryChart).Properties.IsLeftButtonPressed) return; + + _isDragging = true; + _dragStartPoint = e.GetPosition(HistoryChart); + + if (_selectionRect != null) + { + HistoryChart.Plot.Remove(_selectionRect); + _selectionRect = null; + } + + e.Handled = true; + } + + private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isDragging) return; + _isDragging = false; + + if (_selectionRect != null) + { + HistoryChart.Plot.Remove(_selectionRect); + _selectionRect = null; + } + + var endPoint = e.GetPosition(HistoryChart); + var startCoords = PixelToCoordinates(_dragStartPoint); + var endCoords = PixelToCoordinates(endPoint); + + var dx = Math.Abs(endPoint.X - _dragStartPoint.X); + var dy = Math.Abs(endPoint.Y - _dragStartPoint.Y); + + if (dx < 5 && dy < 5) + { + HandleSingleClickSelection(endPoint); + } + else + { + HandleBoxSelection(startCoords, endCoords); + } + + e.Handled = true; + } + + private ScottPlot.Coordinates PixelToCoordinates(Point pos) + { + var lastRender = HistoryChart.Plot.RenderManager.LastRender.FigureRect; + var scaleX = HistoryChart.Bounds.Width > 0 + ? (float)(lastRender.Width / HistoryChart.Bounds.Width) + : 1f; + var scaleY = HistoryChart.Bounds.Height > 0 + ? (float)(lastRender.Height / HistoryChart.Bounds.Height) + : 1f; + var pixel = new ScottPlot.Pixel((float)(pos.X * scaleX), (float)(pos.Y * scaleY)); + return HistoryChart.Plot.GetCoordinates(pixel); + } + + private ScottPlot.Pixel PointToScaledPixel(Point pos) + { + var lastRender = HistoryChart.Plot.RenderManager.LastRender.FigureRect; + var scaleX = HistoryChart.Bounds.Width > 0 + ? (float)(lastRender.Width / HistoryChart.Bounds.Width) + : 1f; + var scaleY = HistoryChart.Bounds.Height > 0 + ? (float)(lastRender.Height / HistoryChart.Bounds.Height) + : 1f; + return new ScottPlot.Pixel((float)(pos.X * scaleX), (float)(pos.Y * scaleY)); + } + + private void HandleSingleClickSelection(Point clickPoint) + { + if (_scatters.Count == 0) return; + + var pixel = PointToScaledPixel(clickPoint); + var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel); + + double bestDist = double.MaxValue; + ScottPlot.DataPoint bestPoint = default; + string bestPlanHash = ""; + bool found = false; + + foreach (var (scatter, _, planHash) in _scatters) + { + var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender); + if (!nearest.IsReal) continue; + + var nearestPixel = HistoryChart.Plot.GetPixel( + new ScottPlot.Coordinates(nearest.X, nearest.Y)); + var d = Math.Sqrt(Math.Pow(nearestPixel.X - pixel.X, 2) + Math.Pow(nearestPixel.Y - pixel.Y, 2)); + + if (d < 30 && d < bestDist) + { + bestDist = d; + bestPoint = nearest; + bestPlanHash = planHash; + found = true; + } + } + + _selectedRowIndices.Clear(); + + if (found) + { + var clickedTime = DateTime.FromOADate(bestPoint.X); + for (int i = 0; i < _historyData.Count; i++) + { + var row = _historyData[i]; + var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc); + if (row.QueryPlanHash == bestPlanHash && + Math.Abs((displayTime - clickedTime).TotalMinutes) < 1) + { + _selectedRowIndices.Add(i); + } + } + } + + HighlightDotsOnChart(_selectedRowIndices); + HighlightGridRows(); + } + + private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinates end) + { + var x1 = Math.Min(start.X, end.X); + var x2 = Math.Max(start.X, end.X); + var y1 = Math.Min(start.Y, end.Y); + var y2 = Math.Max(start.Y, end.Y); + + var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; + _selectedRowIndices.Clear(); + + for (int i = 0; i < _historyData.Count; i++) + { + var row = _historyData[i]; + var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate(); + var yVal = GetMetricValue(row, tag); + + if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2) + _selectedRowIndices.Add(i); + } + + HighlightDotsOnChart(_selectedRowIndices); + HighlightGridRows(); + } + + // ── Hover tooltip ──────────────────────────────────────────────────── + + private void OnChartPointerMoved(object? sender, PointerEventArgs e) + { + if (_scatters.Count == 0) { if (_tooltip != null) _tooltip.IsOpen = false; return; } + + if (_isDragging) + { + var currentPoint = e.GetPosition(HistoryChart); + var startCoords = PixelToCoordinates(_dragStartPoint); + var currentCoords = PixelToCoordinates(currentPoint); + + if (_selectionRect != null) + HistoryChart.Plot.Remove(_selectionRect); + + var x1 = Math.Min(startCoords.X, currentCoords.X); + var x2 = Math.Max(startCoords.X, currentCoords.X); + var y1 = Math.Min(startCoords.Y, currentCoords.Y); + var y2 = Math.Max(startCoords.Y, currentCoords.Y); + + _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2); + _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30); + _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120); + _selectionRect.LineWidth = 1; + HistoryChart.Refresh(); + + if (_tooltip != null) _tooltip.IsOpen = false; + return; + } + + try + { + var pos = e.GetPosition(HistoryChart); + var pixel = PointToScaledPixel(pos); + var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel); + + double bestDist = double.MaxValue; + ScottPlot.DataPoint bestPoint = default; + string bestLabel = ""; + bool found = false; + + foreach (var (scatter, chartLabel, _) in _scatters) + { + var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender); + if (!nearest.IsReal) continue; + + var nearestPixel = HistoryChart.Plot.GetPixel( + new ScottPlot.Coordinates(nearest.X, nearest.Y)); + double ddx = Math.Abs(nearestPixel.X - pixel.X); + double ddy = Math.Abs(nearestPixel.Y - pixel.Y); + + if (ddx < 80 && ddy < bestDist) + { + bestDist = ddy; + bestPoint = nearest; + bestLabel = chartLabel; + found = true; + } + } + + if (found && _tooltipText != null && _tooltip != null) + { + var time = DateTime.FromOADate(bestPoint.X); + var metricLabel = (MetricSelector.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? ""; + _tooltipText.Text = $"{bestLabel}\n{metricLabel}: {bestPoint.Y:N2}\n{time:MM/dd HH:mm}"; + _tooltip.IsOpen = true; + } + else + { + if (_tooltip != null) _tooltip.IsOpen = false; + } + } + catch (Exception) + { + if (_tooltip != null) _tooltip.IsOpen = false; + } + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs index 293a1d8..45637c1 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs @@ -227,709 +227,6 @@ public static string MapOrderByToMetricTag(string orderBy) : "AvgCpuMs"; } - private async System.Threading.Tasks.Task LoadHistoryAsync() - { - _fetchCts?.Cancel(); - _fetchCts?.Dispose(); - _fetchCts = new CancellationTokenSource(); - var ct = _fetchCts.Token; - - StatusText.Text = "Loading..."; - LoadingPanel.IsVisible = true; - - try - { - if (_useFullHistory) - { - _historyData = await QueryStoreService.FetchAggregateHistoryAsync( - _connectionString, _queryHash, _maxHoursBack, ct); - } - else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue) - { - _historyData = await QueryStoreService.FetchAggregateHistoryAsync( - _connectionString, _queryHash, ct: ct, - startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value); - } - else - { - _historyData = await QueryStoreService.FetchAggregateHistoryAsync( - _connectionString, _queryHash, _maxHoursBack, ct); - } - - BuildColorMap(); - HistoryDataGrid.ItemsSource = _historyData; - ApplyColorIndicators(); - - if (_historyData.Count > 0) - { - var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count(); - var totalExec = _historyData.Sum(r => r.CountExecutions); - var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc)); - var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc)); - StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " + - $"{totalExec:N0} total executions | " + - $"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}"; - _dataSummaryText = StatusText.Text; - } - else - { - StatusText.Text = "No history data found for this query."; - } - - UpdateChart(); - PopulateLegendPanel(); - } - catch (OperationCanceledException) - { - StatusText.Text = "Cancelled."; - } - catch (Exception ex) - { - StatusText.Text = ex.Message; - } - finally - { - LoadingPanel.IsVisible = false; - } - } - - private void BuildColorMap() - { - _planHashColorMap.Clear(); - var maxPlans = Services.AppSettingsService.Load().QueryHistoryMaxPlans; - var hashes = _historyData - .GroupBy(r => r.QueryPlanHash) - .OrderByDescending(g => g.Sum(r => r.CountExecutions)) - .Take(maxPlans) - .Select(g => g.Key) - .OrderBy(h => h) - .ToList(); - for (int i = 0; i < hashes.Count; i++) - _planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length]; - } - - private void ApplyColorIndicators() - { - HistoryDataGrid.LoadingRow -= OnDataGridLoadingRow; - HistoryDataGrid.LoadingRow += OnDataGridLoadingRow; - } - - private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e) - { - if (e.Row.DataContext is QueryStoreHistoryRow row && - _planHashColorMap.TryGetValue(row.QueryPlanHash, out var color)) - { - var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B); - var brush = new SolidColorBrush(avColor); - e.Row.Tag = brush; - - if (TryApplyColorIndicator(e.Row, brush)) - return; - } - - e.Row.Loaded -= OnRowLoaded; - e.Row.Loaded += OnRowLoaded; - } - - private void OnRowLoaded(object? sender, RoutedEventArgs e) - { - if (sender is not DataGridRow dgRow) return; - dgRow.Loaded -= OnRowLoaded; - - if (dgRow.Tag is SolidColorBrush brush) - TryApplyColorIndicator(dgRow, brush); - } - - private bool TryApplyColorIndicator(DataGridRow dgRow, SolidColorBrush brush) - { - var presenter = FindVisualChild(dgRow); - if (presenter == null) return false; - - var cell = presenter.Children.OfType().FirstOrDefault(); - if (cell == null) return false; - - var border = FindVisualChild(cell, "ColorIndicator"); - if (border == null) return false; - - border.Background = brush; - return true; - } - - private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual - { - if (parent is T t && (name == null || (t is Control c && c.Name == name))) - return t; - - var children = parent.GetVisualChildren(); - foreach (var child in children) - { - if (child is Avalonia.Visual vc) - { - var found = FindVisualChild(vc, name); - if (found != null) return found; - } - } - return null; - } - - // ── Legend ──────────────────────────────────────────────────────────── - - private void PopulateLegendPanel() - { - LegendItemsPanel.Children.Clear(); - foreach (var (hash, color) in _planHashColorMap.OrderBy(kv => kv.Key)) - { - var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B); - var item = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - Spacing = 6, - Tag = hash, - Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) - }; - item.Children.Add(new Border - { - Width = 12, Height = 12, - CornerRadius = new CornerRadius(2), - Background = new SolidColorBrush(avColor), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center - }); - item.Children.Add(new TextBlock - { - Text = hash, - FontSize = 11, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0)) - }); - item.PointerPressed += OnLegendItemClicked; - LegendItemsPanel.Children.Add(item); - } - } - - private void OnLegendItemClicked(object? sender, PointerPressedEventArgs e) - { - if (sender is not StackPanel panel || panel.Tag is not string planHash) return; - - if (_highlightedPlanHash == planHash) - _highlightedPlanHash = null; - else - _highlightedPlanHash = planHash; - - ApplyPlanHighlight(); - UpdateLegendVisuals(); - } - - private void UpdateLegendVisuals() - { - foreach (var child in LegendItemsPanel.Children) - { - if (child is not StackPanel panel || panel.Tag is not string hash) continue; - var isActive = _highlightedPlanHash == null || _highlightedPlanHash == hash; - panel.Opacity = isActive ? 1.0 : 0.4; - } - } - - private void ApplyPlanHighlight() - { - var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; - - foreach (var (scatter, _, planHash) in _scatters) - { - if (_highlightedPlanHash == null) - { - var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); - scatter.Color = color.WithAlpha(140); - scatter.LineWidth = 2; - scatter.MarkerSize = 8; - } - else if (planHash == _highlightedPlanHash) - { - var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); - scatter.Color = color.WithAlpha(220); - scatter.LineWidth = 4; - scatter.MarkerSize = 10; - } - else - { - var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); - scatter.Color = color.WithAlpha(40); - scatter.LineWidth = 1; - scatter.MarkerSize = 5; - } - } - - if (_avgLine != null) - { - var relevantRows = _highlightedPlanHash != null - ? _historyData.Where(r => r.QueryPlanHash == _highlightedPlanHash).ToList() - : _historyData; - - if (relevantRows.Count > 0) - { - var avg = relevantRows.Select(r => GetMetricValue(r, tag)).Average(); - _avgLine.Y = avg; - _avgLine.Text = $"avg: {avg:N0}"; - _avgLine.IsVisible = true; - } - else - { - _avgLine.IsVisible = false; - } - } - - HistoryChart.Refresh(); - } - - private void LegendToggle_Click(object? sender, RoutedEventArgs e) - { - _legendExpanded = !_legendExpanded; - LegendPanel.IsVisible = _legendExpanded; - LegendArrow.Text = _legendExpanded ? "\u25b2" : "\u25bc"; - } - - // ── Chart ──────────────────────────────────────────────────────────── - - private void UpdateChart() - { - HistoryChart.Plot.Clear(); - _scatters.Clear(); - _selectionRect = null; - _highlightMarkers.Clear(); - _avgLine = null; - _highlightedPlanHash = null; - - if (_historyData.Count == 0) - { - HistoryChart.Refresh(); - return; - } - - var selected = MetricSelector.SelectedItem as ComboBoxItem; - var tag = selected?.Tag?.ToString() ?? "AvgCpuMs"; - var label = selected?.Content?.ToString() ?? "Avg CPU (ms)"; - - var planGroups = _historyData - .GroupBy(r => r.QueryPlanHash) - .OrderBy(g => g.Key) - .ToList(); - - foreach (var group in planGroups) - { - var planHash = group.Key; - var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); - - var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList(); - var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); - var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray(); - - var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); - scatter.Color = color.WithAlpha(140); - scatter.LegendText = ""; - scatter.LineWidth = 2; - scatter.MarkerSize = 8; - scatter.MarkerShape = MarkerShape.FilledCircle; - scatter.MarkerLineColor = ScottPlot.Color.FromHex("#AAAAAA"); - scatter.MarkerLineWidth = 1f; - - _scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash)); - } - - var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray(); - if (allValues.Length > 0) - { - var avg = allValues.Average(); - _avgLine = HistoryChart.Plot.Add.HorizontalLine(avg); - _avgLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150); - _avgLine.LineWidth = 2f; - _avgLine.LinePattern = LinePattern.DenselyDashed; - _avgLine.Text = $"avg: {avg:N0}"; - _avgLine.LabelFontColor = ScottPlot.Color.FromHex("#E4E6EB"); - _avgLine.LabelFontSize = 11; - _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(170); - _avgLine.LabelOppositeAxis = false; - _avgLine.LabelRotation = 0; - _avgLine.LabelAlignment = Alignment.LowerLeft; - _avgLine.LabelOffsetX = 38; - _avgLine.LabelOffsetY = -8; - } - - HistoryChart.Plot.Axes.AutoScale(); - var yLimits = HistoryChart.Plot.Axes.GetLimits(); - HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1); - - HistoryChart.Plot.HideLegend(); - - ConfigureSmartXAxis(); - - HistoryChart.Plot.YLabel(label); - ApplyDarkTheme(); - HistoryChart.Refresh(); - } - - private void ConfigureSmartXAxis() - { - if (_historyData.Count == 0) return; - - var minTime = _historyData.Min(r => r.IntervalStartUtc); - var maxTime = _historyData.Max(r => r.IntervalStartUtc); - var span = maxTime - minTime; - - HistoryChart.Plot.Axes.DateTimeTicksBottom(); - - if (span.TotalHours <= 48) - { - HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#E4E6EB"); - HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic - { - LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd") - }; - } - else if (span.TotalDays <= 14) - { - HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic - { - LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd") - }; - } - else - { - HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic - { - LabelFormatter = dt => dt.ToString("MM/dd\nyyyy") - }; - } - } - - // ── Dot highlighting on chart ──────────────────────────────────────── - - private void ClearHighlightMarkers() - { - foreach (var m in _highlightMarkers) - HistoryChart.Plot.Remove(m); - _highlightMarkers.Clear(); - } - - private void HighlightDotsOnChart(HashSet rowIndices) - { - ClearHighlightMarkers(); - if (rowIndices.Count == 0) - { - HistoryChart.Refresh(); - return; - } - - var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; - - var groups = rowIndices - .Where(i => i >= 0 && i < _historyData.Count) - .Select(i => _historyData[i]) - .GroupBy(r => r.QueryPlanHash); - - foreach (var group in groups) - { - var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]); - var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); - var ys = group.Select(r => GetMetricValue(r, tag)).ToArray(); - - var highlight = HistoryChart.Plot.Add.Scatter(xs, ys); - highlight.LineWidth = 0; - highlight.MarkerSize = 14; - highlight.MarkerShape = MarkerShape.FilledCircle; - highlight.Color = color; - highlight.MarkerLineColor = ScottPlot.Colors.White; - highlight.MarkerLineWidth = 2.5f; - - _highlightMarkers.Add(highlight); - } - - HistoryChart.Refresh(); - } - - // ── Box selection ──────────────────────────────────────────────────── - - private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (!e.GetCurrentPoint(HistoryChart).Properties.IsLeftButtonPressed) return; - - _isDragging = true; - _dragStartPoint = e.GetPosition(HistoryChart); - - if (_selectionRect != null) - { - HistoryChart.Plot.Remove(_selectionRect); - _selectionRect = null; - } - - e.Handled = true; - } - - private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (!_isDragging) return; - _isDragging = false; - - if (_selectionRect != null) - { - HistoryChart.Plot.Remove(_selectionRect); - _selectionRect = null; - } - - var endPoint = e.GetPosition(HistoryChart); - var startCoords = PixelToCoordinates(_dragStartPoint); - var endCoords = PixelToCoordinates(endPoint); - - var dx = Math.Abs(endPoint.X - _dragStartPoint.X); - var dy = Math.Abs(endPoint.Y - _dragStartPoint.Y); - - if (dx < 5 && dy < 5) - { - HandleSingleClickSelection(endPoint); - } - else - { - HandleBoxSelection(startCoords, endCoords); - } - - e.Handled = true; - } - - private ScottPlot.Coordinates PixelToCoordinates(Point pos) - { - var lastRender = HistoryChart.Plot.RenderManager.LastRender.FigureRect; - var scaleX = HistoryChart.Bounds.Width > 0 - ? (float)(lastRender.Width / HistoryChart.Bounds.Width) - : 1f; - var scaleY = HistoryChart.Bounds.Height > 0 - ? (float)(lastRender.Height / HistoryChart.Bounds.Height) - : 1f; - var pixel = new ScottPlot.Pixel((float)(pos.X * scaleX), (float)(pos.Y * scaleY)); - return HistoryChart.Plot.GetCoordinates(pixel); - } - - private ScottPlot.Pixel PointToScaledPixel(Point pos) - { - var lastRender = HistoryChart.Plot.RenderManager.LastRender.FigureRect; - var scaleX = HistoryChart.Bounds.Width > 0 - ? (float)(lastRender.Width / HistoryChart.Bounds.Width) - : 1f; - var scaleY = HistoryChart.Bounds.Height > 0 - ? (float)(lastRender.Height / HistoryChart.Bounds.Height) - : 1f; - return new ScottPlot.Pixel((float)(pos.X * scaleX), (float)(pos.Y * scaleY)); - } - - private void HandleSingleClickSelection(Point clickPoint) - { - if (_scatters.Count == 0) return; - - var pixel = PointToScaledPixel(clickPoint); - var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel); - - double bestDist = double.MaxValue; - ScottPlot.DataPoint bestPoint = default; - string bestPlanHash = ""; - bool found = false; - - foreach (var (scatter, _, planHash) in _scatters) - { - var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender); - if (!nearest.IsReal) continue; - - var nearestPixel = HistoryChart.Plot.GetPixel( - new ScottPlot.Coordinates(nearest.X, nearest.Y)); - var d = Math.Sqrt(Math.Pow(nearestPixel.X - pixel.X, 2) + Math.Pow(nearestPixel.Y - pixel.Y, 2)); - - if (d < 30 && d < bestDist) - { - bestDist = d; - bestPoint = nearest; - bestPlanHash = planHash; - found = true; - } - } - - _selectedRowIndices.Clear(); - - if (found) - { - var clickedTime = DateTime.FromOADate(bestPoint.X); - for (int i = 0; i < _historyData.Count; i++) - { - var row = _historyData[i]; - var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc); - if (row.QueryPlanHash == bestPlanHash && - Math.Abs((displayTime - clickedTime).TotalMinutes) < 1) - { - _selectedRowIndices.Add(i); - } - } - } - - HighlightDotsOnChart(_selectedRowIndices); - HighlightGridRows(); - } - - private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinates end) - { - var x1 = Math.Min(start.X, end.X); - var x2 = Math.Max(start.X, end.X); - var y1 = Math.Min(start.Y, end.Y); - var y2 = Math.Max(start.Y, end.Y); - - var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs"; - _selectedRowIndices.Clear(); - - for (int i = 0; i < _historyData.Count; i++) - { - var row = _historyData[i]; - var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate(); - var yVal = GetMetricValue(row, tag); - - if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2) - _selectedRowIndices.Add(i); - } - - HighlightDotsOnChart(_selectedRowIndices); - HighlightGridRows(); - } - - private void HighlightGridRows() - { - // Update row backgrounds directly without resetting ItemsSource - // (which would wipe sort state and scroll position) - foreach (var row in HistoryDataGrid.GetVisualDescendants().OfType()) - { - var idx = row.Index; - row.Background = _selectedRowIndices.Contains(idx) - ? new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247)) - : Brushes.Transparent; - } - - // Also keep the LoadingRow handler for rows that get virtualized in/out - HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow; - HistoryDataGrid.LoadingRow += OnHighlightLoadingRow; - - // Scroll to first selected row - if (_selectedRowIndices.Count > 0) - { - var firstIdx = _selectedRowIndices.Min(); - if (firstIdx < _historyData.Count) - HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null); - } - } - - private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e) - { - var idx = e.Row.Index; - if (_selectedRowIndices.Contains(idx)) - { - e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247)); - } - else - { - e.Row.Background = Brushes.Transparent; - } - } - - // ── Grid row click → chart highlight ───────────────────────────────── - - private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - _selectedRowIndices.Clear(); - if (HistoryDataGrid.SelectedItems != null) - { - foreach (var item in HistoryDataGrid.SelectedItems) - { - if (item is QueryStoreHistoryRow row) - { - var idx = _historyData.IndexOf(row); // O(n) but list is small (<500 items) - if (idx >= 0) - _selectedRowIndices.Add(idx); - } - } - } - - HighlightDotsOnChart(_selectedRowIndices); - } - - // ── Hover tooltip ──────────────────────────────────────────────────── - - private void OnChartPointerMoved(object? sender, PointerEventArgs e) - { - if (_scatters.Count == 0) { if (_tooltip != null) _tooltip.IsOpen = false; return; } - - if (_isDragging) - { - var currentPoint = e.GetPosition(HistoryChart); - var startCoords = PixelToCoordinates(_dragStartPoint); - var currentCoords = PixelToCoordinates(currentPoint); - - if (_selectionRect != null) - HistoryChart.Plot.Remove(_selectionRect); - - var x1 = Math.Min(startCoords.X, currentCoords.X); - var x2 = Math.Max(startCoords.X, currentCoords.X); - var y1 = Math.Min(startCoords.Y, currentCoords.Y); - var y2 = Math.Max(startCoords.Y, currentCoords.Y); - - _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2); - _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30); - _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120); - _selectionRect.LineWidth = 1; - HistoryChart.Refresh(); - - if (_tooltip != null) _tooltip.IsOpen = false; - return; - } - - try - { - var pos = e.GetPosition(HistoryChart); - var pixel = PointToScaledPixel(pos); - var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel); - - double bestDist = double.MaxValue; - ScottPlot.DataPoint bestPoint = default; - string bestLabel = ""; - bool found = false; - - foreach (var (scatter, chartLabel, _) in _scatters) - { - var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender); - if (!nearest.IsReal) continue; - - var nearestPixel = HistoryChart.Plot.GetPixel( - new ScottPlot.Coordinates(nearest.X, nearest.Y)); - double ddx = Math.Abs(nearestPixel.X - pixel.X); - double ddy = Math.Abs(nearestPixel.Y - pixel.Y); - - if (ddx < 80 && ddy < bestDist) - { - bestDist = ddy; - bestPoint = nearest; - bestLabel = chartLabel; - found = true; - } - } - - if (found && _tooltipText != null && _tooltip != null) - { - var time = DateTime.FromOADate(bestPoint.X); - var metricLabel = (MetricSelector.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? ""; - _tooltipText.Text = $"{bestLabel}\n{metricLabel}: {bestPoint.Y:N2}\n{time:MM/dd HH:mm}"; - _tooltip.IsOpen = true; - } - else - { - if (_tooltip != null) _tooltip.IsOpen = false; - } - } - catch (Exception) - { - if (_tooltip != null) _tooltip.IsOpen = false; - } - } - private static double GetMetricValue(QueryStoreHistoryRow row, string tag) => tag switch { "AvgCpuMs" => row.AvgCpuMs, @@ -1021,84 +318,4 @@ private void Close_Click(object? sender, RoutedEventArgs e) window.Close(); } - /// - /// Returns the plan hash of the currently selected row(s), or null if none. - /// - private string? GetSelectedPlanHash() - { - // From grid selection - if (HistoryDataGrid.SelectedItem is QueryStoreHistoryRow row) - return row.QueryPlanHash; - - // From chart selection - if (_selectedRowIndices.Count > 0) - { - var idx = _selectedRowIndices.First(); - if (idx >= 0 && idx < _historyData.Count) - return _historyData[idx].QueryPlanHash; - } - - return null; - } - - private ContextMenu CreatePlanContextMenu() - { - var loadFirstItem = new MenuItem { Header = "Load Oldest Plan for This Hash" }; - var loadLastItem = new MenuItem { Header = "Load Newest Plan for This Hash" }; - - loadFirstItem.Click += (_, _) => LoadPlanFromSelection(oldest: true); - loadLastItem.Click += (_, _) => LoadPlanFromSelection(oldest: false); - - var menu = new ContextMenu - { - Items = { loadFirstItem, loadLastItem } - }; - - menu.Opening += (_, _) => - { - var hasSelection = GetSelectedPlanHash() != null; - foreach (var item in menu.Items.OfType()) - item.IsEnabled = hasSelection; - }; - - return menu; - } - - private void BuildContextMenu() - { - HistoryDataGrid.ContextMenu = CreatePlanContextMenu(); - HistoryChart.ContextMenu = CreatePlanContextMenu(); - } - - private async void LoadPlanFromSelection(bool oldest) - { - if (_isLoadingPlan) return; - var planHash = GetSelectedPlanHash(); - if (string.IsNullOrEmpty(planHash)) return; - - _isLoadingPlan = true; - StatusText.Text = "Loading plan…"; - try - { - var plan = await QueryStoreService.FetchPlanByHashAsync( - _connectionString, planHash, oldest); - - if (plan == null || string.IsNullOrEmpty(plan.PlanXml)) - { - StatusText.Text = "Plan not found"; - return; - } - - StatusText.Text = _dataSummaryText; - PlanLoadRequested?.Invoke(this, new HistoryPlanLoadEventArgs(plan)); - } - catch (Exception ex) - { - StatusText.Text = $"Error loading plan: {ex.Message}"; - } - finally - { - _isLoadingPlan = false; - } - } } diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.BarCards.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.BarCards.cs new file mode 100644 index 0000000..793de04 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.BarCards.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreOverviewControl : UserControl +{ + // ── Bar Chart Cards ───────────────────────────────────────────────────── + + /// Unified color map: same database → same color across all cards. + private Dictionary _dbColorMap = new(); + + private void DrawBarCards() + { + // Build a single color map based on top-N by total CPU (union across all databases) + _dbColorMap.Clear(); + var ranked = _metrics + .OrderByDescending(m => m.TotalCpu) + .Select(m => m.DatabaseName) + .ToList(); + var topDbs = ranked.Take(_topN).ToList(); + for (int i = 0; i < topDbs.Count && i < _palette.Length; i++) + _dbColorMap[topDbs[i]] = _palette[i]; + + DrawMetricRow(TotalMetricsGrid, isTotal: true, topDbs); + DrawMetricRow(AvgMetricsGrid, isTotal: false, topDbs); + } + + private void DrawMetricRow(Grid grid, bool isTotal, List topDbs) + { + grid.Children.Clear(); + grid.ColumnDefinitions.Clear(); + + var metricNames = isTotal + ? new[] { "Total CPU", "Total Duration", "Executions", "Total Reads", "Total Writes", "Total Physical Reads", "Total Memory" } + : new[] { "Avg CPU", "Avg Duration", "Executions", "Avg Reads", "Avg Writes", "Avg Physical Reads", "Avg Memory" }; + + for (int i = 0; i < metricNames.Length; i++) + grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star)); + + for (int mi = 0; mi < metricNames.Length; mi++) + { + var card = CreateBarCard(metricNames[mi], mi, isTotal, topDbs, _dbColorMap); + Grid.SetColumn(card, mi); + card.Margin = new Thickness(mi == 0 ? 0 : 5, 0, mi == metricNames.Length - 1 ? 0 : 5, 0); + grid.Children.Add(card); + } + } + + private Border CreateBarCard(string title, int metricIndex, bool isTotal, + List topDbs, Dictionary dbColors) + { + var border = new Border + { + Background = this.FindResource("BackgroundLightBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#22252D")), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6), + ClipToBounds = true + }; + + var stack = new StackPanel { Spacing = 4 }; + + // Title + stack.Children.Add(new TextBlock + { + Text = title, + FontSize = 11, + FontWeight = FontWeight.SemiBold, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center + }); + + // Build bar data + var bars = new List<(string db, double value, Color color)>(); + double othersValue = 0; + + foreach (var m in _metrics) + { + var val = GetMetricValue(m, metricIndex, isTotal); + if (topDbs.Contains(m.DatabaseName)) + bars.Add((m.DatabaseName, val, dbColors.GetValueOrDefault(m.DatabaseName, OthersColor))); + else + othersValue += val; + } + + if (othersValue > 0) + bars.Add(("Others", othersValue, OthersColor)); + + var maxVal = bars.Count > 0 ? bars.Max(b => b.value) : 1; + if (maxVal <= 0) maxVal = 1; + + foreach (var (db, value, color) in bars.OrderByDescending(b => b.value)) + { + var barContainer = new Border + { + Height = 22, + CornerRadius = new CornerRadius(3), + Background = new SolidColorBrush(color), + ClipToBounds = true, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, + Width = double.NaN, + Margin = new Thickness(0, 1) + }; + + // Determine font color based on luminance + var luminance = 0.299 * color.R + 0.587 * color.G + 0.114 * color.B; + var fontColor = luminance > 128 ? Colors.Black : Colors.White; + + var barText = new TextBlock + { + Text = db.Length > 15 ? db[..12] + "..." : db, + FontSize = 10, + Foreground = new SolidColorBrush(fontColor), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + Margin = new Thickness(4, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + + barContainer.Child = barText; + ToolTip.SetTip(barContainer, $"{db}: {value:N0}"); + + // Context menu for drill-down + if (db != "Others") + { + var menu = new ContextMenu(); + var menuItem = new MenuItem { Header = "Drill Down to DB Query Store" }; + var dbName = db; + menuItem.Click += (_, _) => DrillDownRequested?.Invoke(this, new DrillDownEventArgs(dbName, _slicerStartUtc, _slicerEndUtc)); + menu.Items.Add(menuItem); + barContainer.ContextMenu = menu; + } + + // Scale width by ratio via a wrapping grid + var barGrid = new Grid(); + barGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(value / maxVal, GridUnitType.Star))); + barGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1 - value / maxVal, GridUnitType.Star))); + Grid.SetColumn(barContainer, 0); + barGrid.Children.Add(barContainer); + + stack.Children.Add(barGrid); + } + + border.Child = stack; + return border; + } + + private static double GetMetricValue(DatabaseMetrics m, int metricIndex, bool isTotal) + { + if (isTotal) + { + return metricIndex switch + { + 0 => m.TotalCpu, + 1 => m.TotalDuration, + 2 => m.TotalExecutions, + 3 => m.TotalReads, + 4 => m.TotalWrites, + 5 => m.TotalPhysicalReads, + 6 => m.TotalMemory, + _ => 0 + }; + } + return metricIndex switch + { + 0 => m.AvgCpu, + 1 => m.AvgDuration, + 2 => m.TotalExecutions, // Executions is the same + 3 => m.AvgReads, + 4 => m.AvgWrites, + 5 => m.AvgPhysicalReads, + 6 => m.AvgMemory, + _ => 0 + }; + } +} diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.Donut.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.Donut.cs new file mode 100644 index 0000000..69144e2 --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.Donut.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreOverviewControl : UserControl +{ + // ── Donut Chart ────────────────────────────────────────────────────────── + + private void DrawDonut() + { + DonutCanvas.Children.Clear(); + if (_states.Count == 0) return; + + var w = DonutCanvas.Bounds.Width; + var h = DonutCanvas.Bounds.Height; + if (w < 10 || h < 10) return; + + var cx = w / 2; + var cy = h / 2; + var radius = Math.Min(w, h) / 2 - 10; + var innerRadius = radius * 0.6; + + int rwCount = _states.Count(s => s.State == QueryStoreState.ReadWrite); + int roCount = _states.Count(s => s.State == QueryStoreState.ReadOnly); + int offCount = _states.Count(s => s.State == QueryStoreState.Off); + int errCount = _states.Count(s => s.State == QueryStoreState.Error); + int total = _states.Count; + int activeCount = rwCount + roCount; + + var segmentInfos = new List<(int count, string label, QueryStoreState state, Color color)>(); + if (rwCount > 0) segmentInfos.Add((rwCount, "Read Write", QueryStoreState.ReadWrite, ReadWriteColor)); + if (roCount > 0) segmentInfos.Add((roCount, "Read Only", QueryStoreState.ReadOnly, ReadOnlyColor)); + if (offCount > 0) segmentInfos.Add((offCount, "OFF", QueryStoreState.Off, OffColor)); + if (errCount > 0) segmentInfos.Add((errCount, "Error", QueryStoreState.Error, ErrorColor)); + + double startAngle = -Math.PI / 2; + foreach (var (count, label, state, color) in segmentInfos) + { + var fraction = (double)count / total; + var sweepAngle = fraction * 2 * Math.PI; + var path = CreateArcPath(cx, cy, radius, innerRadius, startAngle, startAngle + sweepAngle, color); + + // Tooltip with details + var pct = fraction * 100; + var tipText = $"{label}: {count} database{(count != 1 ? "s" : "")} ({pct:F0}%)"; + ToolTip.SetTip(path, tipText); + ToolTip.SetShowDelay(path, 200); + + // Click → popup with database list + var capturedState = state; + path.Cursor = new Cursor(StandardCursorType.Hand); + path.PointerPressed += (_, e) => + { + e.Handled = true; + ShowDonutPopup(capturedState); + }; + + DonutCanvas.Children.Add(path); + + // Percentage label on the arc midpoint + if (fraction > 0.05) // only show label if segment is large enough + { + var midAngle = startAngle + sweepAngle / 2; + var labelR = (radius + innerRadius) / 2; + var lx = cx + labelR * Math.Cos(midAngle); + var ly = cy + labelR * Math.Sin(midAngle); + var pctLabel = new TextBlock + { + Text = $"{pct:F0}%", + FontSize = 10, + FontWeight = FontWeight.SemiBold, + Foreground = Brushes.White, + }; + pctLabel.Measure(Size.Infinity); + Canvas.SetLeft(pctLabel, lx - pctLabel.DesiredSize.Width / 2); + Canvas.SetTop(pctLabel, ly - pctLabel.DesiredSize.Height / 2); + DonutCanvas.Children.Add(pctLabel); + } + + startAngle += sweepAngle; + } + + // Center text + var centerText = new TextBlock + { + Text = $"{activeCount}/{total}", + FontSize = 16, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + }; + centerText.Measure(Size.Infinity); + Canvas.SetLeft(centerText, cx - centerText.DesiredSize.Width / 2); + Canvas.SetTop(centerText, cy - centerText.DesiredSize.Height / 2); + DonutCanvas.Children.Add(centerText); + + // Legend below + var legendY = cy + radius + 5; + DrawLegendItem(DonutCanvas, 4, legendY, ReadWriteColor, $"RW: {rwCount}"); + DrawLegendItem(DonutCanvas, 4, legendY + 16, ReadOnlyColor, $"RO: {roCount}"); + DrawLegendItem(DonutCanvas, 4, legendY + 32, OffColor, $"OFF: {offCount}"); + if (errCount > 0) DrawLegendItem(DonutCanvas, 4, legendY + 48, ErrorColor, $"Err: {errCount}"); + } + + private void ShowDonutPopup(QueryStoreState state) + { + var dbs = _states.Where(s => s.State == state).OrderBy(s => s.DatabaseName).ToList(); + var stateLabel = state switch + { + QueryStoreState.ReadWrite => "Read Write", + QueryStoreState.ReadOnly => "Read Only", + QueryStoreState.Error => "Error", + _ => "OFF" + }; + + var stack = new StackPanel { Spacing = 2, Margin = new Thickness(4) }; + stack.Children.Add(new TextBlock + { + Text = $"{stateLabel} ({dbs.Count})", + FontSize = 12, + FontWeight = FontWeight.Bold, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Margin = new Thickness(0, 0, 0, 4) + }); + + foreach (var db in dbs) + { + var text = db.DatabaseName; + if (db.State == QueryStoreState.Error && !string.IsNullOrEmpty(db.ErrorMessage)) + text += $" — {db.ErrorMessage}"; + stack.Children.Add(new TextBlock + { + Text = text, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + }); + } + + var popup = new Popup + { + PlacementTarget = DonutCanvas, + Placement = PlacementMode.Pointer, + IsLightDismissEnabled = true, + Child = new Border + { + Background = this.FindResource("BackgroundLightBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#22252D")), + BorderBrush = this.FindResource("BorderBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#3A3D45")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10), + MinWidth = 180, + MaxHeight = 300, + Child = new ScrollViewer { Content = stack } + } + }; + + // Add popup to the visual tree temporarily + if (DonutCanvas.Parent is Panel parentPanel) + { + parentPanel.Children.Add(popup); + popup.IsOpen = true; + popup.Closed += (_, _) => parentPanel.Children.Remove(popup); + } + } + + private void DrawLegendItem(Canvas canvas, double x, double y, Color color, string text) + { + if (y + 14 > canvas.Bounds.Height) return; + var rect = new Rectangle { Width = 10, Height = 10, Fill = new SolidColorBrush(color) }; + Canvas.SetLeft(rect, x); + Canvas.SetTop(rect, y); + canvas.Children.Add(rect); + + var tb = new TextBlock + { + Text = text, + FontSize = 11, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")) + }; + Canvas.SetLeft(tb, x + 14); + Canvas.SetTop(tb, y - 1); + canvas.Children.Add(tb); + } + + private Path CreateArcPath(double cx, double cy, double outerR, double innerR, + double startAngle, double endAngle, Color fill) + { + var largeArc = (endAngle - startAngle) > Math.PI; + var outerStart = new Point(cx + outerR * Math.Cos(startAngle), cy + outerR * Math.Sin(startAngle)); + var outerEnd = new Point(cx + outerR * Math.Cos(endAngle), cy + outerR * Math.Sin(endAngle)); + var innerStart = new Point(cx + innerR * Math.Cos(endAngle), cy + innerR * Math.Sin(endAngle)); + var innerEnd = new Point(cx + innerR * Math.Cos(startAngle), cy + innerR * Math.Sin(startAngle)); + + var fig = new PathFigure { StartPoint = outerStart, IsClosed = true }; + fig.Segments!.Add(new ArcSegment + { + Point = outerEnd, + Size = new Size(outerR, outerR), + IsLargeArc = largeArc, + SweepDirection = SweepDirection.Clockwise + }); + fig.Segments.Add(new LineSegment { Point = innerStart }); + fig.Segments.Add(new ArcSegment + { + Point = innerEnd, + Size = new Size(innerR, innerR), + IsLargeArc = largeArc, + SweepDirection = SweepDirection.CounterClockwise + }); + + var geo = new PathGeometry(); + geo.Figures!.Add(fig); + return new Path { Data = geo, Fill = new SolidColorBrush(fill) }; + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs new file mode 100644 index 0000000..0583adc --- /dev/null +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using PlanViewer.App.Services; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.App.Controls; + +public partial class QueryStoreOverviewControl : UserControl +{ + // ── Wait Stats Chart (stacked by database) ────────────────────────────── + + private void DrawWaitStatsChart() + { + WaitStatsCanvas.Children.Clear(); + if (_waitSlices.Count == 0) + { + var msg = new TextBlock + { + Text = _supportsWaitStats ? "No wait stats data" : "Wait stats not supported (SQL 2017+ required)", + FontSize = 10, + Foreground = this.FindResource("ForegroundMutedBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#B0B6C0")), + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + }; + Canvas.SetLeft(msg, 10); + Canvas.SetTop(msg, 20); + WaitStatsCanvas.Children.Add(msg); + return; + } + + if (_dbColorMap.Count == 0) return; // not yet initialized by DrawBarCards + + var w = WaitStatsBorder.Bounds.Width; + var h = WaitStatsBorder.Bounds.Height; + if (w < 10 || h < 10) return; + + const double paddingTop = 4; + const double paddingBottom = 16; + var chartH = h - paddingTop - paddingBottom; + if (chartH <= 0) return; + + // Consolidate: sum ALL wait ratios per database per hour (ignore wait category) + var dbHourData = _waitSlices + .GroupBy(s => new { s.DatabaseName, s.IntervalStartUtc }) + .Select(g => (Db: g.Key.DatabaseName, Hour: g.Key.IntervalStartUtc, Total: g.Sum(x => x.WaitAmountHours))) + .ToList(); + + if (dbHourData.Count == 0) return; + + // Build complete hourly timeline + var allHours = dbHourData.Select(x => x.Hour).Distinct().OrderBy(x => x).ToList(); + var n = allHours.Count; + if (n == 0) return; + + // Use the same top-N databases + Others as the bar cards + var topDbs = _dbColorMap.Keys.ToList(); + + // Compute per-hour totals for each database group + var hourLookup = dbHourData + .GroupBy(x => x.Hour) + .ToDictionary(g => g.Key, g => g.ToList()); + + // Find max stacked total for Y scaling + double maxTotal = 0; + double totalWaitSum = 0; + int bucketsWithData = 0; + foreach (var hour in allHours) + { + if (!hourLookup.TryGetValue(hour, out var items)) continue; + var total = items.Sum(x => x.Total); + if (total > maxTotal) maxTotal = total; + totalWaitSum += total; + bucketsWithData++; + } + if (maxTotal <= 0) maxTotal = 1; + + var stepX = w / n; + var barGap = Math.Min(2.0, Math.Max(0.5, stepX * 0.1)); + + for (int i = 0; i < n; i++) + { + var hour = allHours[i]; + if (!hourLookup.TryGetValue(hour, out var items)) continue; + + // Aggregate per database + var dbTotals = new Dictionary(); + double othersTotal = 0; + foreach (var item in items) + { + if (topDbs.Contains(item.Db)) + dbTotals[item.Db] = dbTotals.GetValueOrDefault(item.Db) + item.Total; + else + othersTotal += item.Total; + } + + double y = paddingTop + chartH; // bottom + var x = i * stepX; + + // Draw stacked bars: top databases first, then others + foreach (var db in topDbs) + { + var val = dbTotals.GetValueOrDefault(db); + if (val <= 0) continue; + var segH = (val / maxTotal) * chartH; + y -= segH; + + var color = _dbColorMap.GetValueOrDefault(db, OthersColor); + var rect = new Rectangle + { + Width = Math.Max(1, stepX - barGap), + Height = Math.Max(0.5, segH), + Fill = new SolidColorBrush(color), + }; + Canvas.SetLeft(rect, x); + Canvas.SetTop(rect, y); + WaitStatsCanvas.Children.Add(rect); + + ToolTip.SetTip(rect, $"{db}: {WaitRatioFormatter.Format(val)}"); + ToolTip.SetShowDelay(rect, 200); + } + + if (othersTotal > 0) + { + var segH = (othersTotal / maxTotal) * chartH; + y -= segH; + var rect = new Rectangle + { + Width = Math.Max(1, stepX - barGap), + Height = Math.Max(0.5, segH), + Fill = new SolidColorBrush(OthersColor), + }; + Canvas.SetLeft(rect, x); + Canvas.SetTop(rect, y); + WaitStatsCanvas.Children.Add(rect); + ToolTip.SetTip(rect, $"Others: {WaitRatioFormatter.Format(othersTotal)}"); + ToolTip.SetShowDelay(rect, 200); + } + } + + // X-axis labels at day boundaries + var labelBrush = new SolidColorBrush(Color.Parse("#E4E6EB")); + for (int i = 0; i < n; i++) + { + if (allHours[i].Hour == 0) + { + var xDay = i * stepX; + WaitStatsCanvas.Children.Add(new Line + { + StartPoint = new Point(xDay, paddingTop), + EndPoint = new Point(xDay, paddingTop + chartH), + Stroke = labelBrush, + StrokeThickness = 1, + StrokeDashArray = [4, 4], + Opacity = 0.3, + }); + var tb = new TextBlock + { + Text = TimeDisplayHelper.FormatForDisplay(allHours[i], "MM/dd"), + FontSize = 8, + Foreground = labelBrush, + }; + Canvas.SetLeft(tb, xDay + 2); + Canvas.SetTop(tb, h - paddingBottom + 1); + WaitStatsCanvas.Children.Add(tb); + } + } + + // ── Horizontal dashed average line ───────────────────────────────── + if (bucketsWithData > 0) + { + var avgWait = totalWaitSum / bucketsWithData; + if (avgWait > 0 && avgWait <= maxTotal) + { + var avgY = paddingTop + chartH - (avgWait / maxTotal) * chartH; + var dashBrush = new SolidColorBrush(Color.Parse("#E4E6EB")); + var avgLine = new Line + { + StartPoint = new Point(0, avgY), + EndPoint = new Point(w, avgY), + Stroke = dashBrush, + StrokeThickness = 1, + StrokeDashArray = [6, 3], + Opacity = 0.7, + }; + WaitStatsCanvas.Children.Add(avgLine); + + var avgLabel = new Border + { + Background = new SolidColorBrush(Color.Parse("#B0D0D0D0")), + CornerRadius = new CornerRadius(3), + Padding = new Thickness(4, 1), + Child = new TextBlock + { + Text = $"avg:{WaitRatioFormatter.Format(avgWait)}", + FontSize = 10, + Foreground = Brushes.Black, + }, + }; + Canvas.SetLeft(avgLabel, 2); + Canvas.SetTop(avgLabel, Math.Max(0, avgY - 16)); + WaitStatsCanvas.Children.Add(avgLabel); + } + } + } + +} diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs index 9fa2d43..afbc778 100644 --- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs @@ -209,217 +209,6 @@ private void UpdateWaitStatsWarning(List<(string Database, string Error)> errors WaitStatsWarning.IsVisible = true; } - // ── Donut Chart ────────────────────────────────────────────────────────── - - private void DrawDonut() - { - DonutCanvas.Children.Clear(); - if (_states.Count == 0) return; - - var w = DonutCanvas.Bounds.Width; - var h = DonutCanvas.Bounds.Height; - if (w < 10 || h < 10) return; - - var cx = w / 2; - var cy = h / 2; - var radius = Math.Min(w, h) / 2 - 10; - var innerRadius = radius * 0.6; - - int rwCount = _states.Count(s => s.State == QueryStoreState.ReadWrite); - int roCount = _states.Count(s => s.State == QueryStoreState.ReadOnly); - int offCount = _states.Count(s => s.State == QueryStoreState.Off); - int errCount = _states.Count(s => s.State == QueryStoreState.Error); - int total = _states.Count; - int activeCount = rwCount + roCount; - - var segmentInfos = new List<(int count, string label, QueryStoreState state, Color color)>(); - if (rwCount > 0) segmentInfos.Add((rwCount, "Read Write", QueryStoreState.ReadWrite, ReadWriteColor)); - if (roCount > 0) segmentInfos.Add((roCount, "Read Only", QueryStoreState.ReadOnly, ReadOnlyColor)); - if (offCount > 0) segmentInfos.Add((offCount, "OFF", QueryStoreState.Off, OffColor)); - if (errCount > 0) segmentInfos.Add((errCount, "Error", QueryStoreState.Error, ErrorColor)); - - double startAngle = -Math.PI / 2; - foreach (var (count, label, state, color) in segmentInfos) - { - var fraction = (double)count / total; - var sweepAngle = fraction * 2 * Math.PI; - var path = CreateArcPath(cx, cy, radius, innerRadius, startAngle, startAngle + sweepAngle, color); - - // Tooltip with details - var pct = fraction * 100; - var tipText = $"{label}: {count} database{(count != 1 ? "s" : "")} ({pct:F0}%)"; - ToolTip.SetTip(path, tipText); - ToolTip.SetShowDelay(path, 200); - - // Click → popup with database list - var capturedState = state; - path.Cursor = new Cursor(StandardCursorType.Hand); - path.PointerPressed += (_, e) => - { - e.Handled = true; - ShowDonutPopup(capturedState); - }; - - DonutCanvas.Children.Add(path); - - // Percentage label on the arc midpoint - if (fraction > 0.05) // only show label if segment is large enough - { - var midAngle = startAngle + sweepAngle / 2; - var labelR = (radius + innerRadius) / 2; - var lx = cx + labelR * Math.Cos(midAngle); - var ly = cy + labelR * Math.Sin(midAngle); - var pctLabel = new TextBlock - { - Text = $"{pct:F0}%", - FontSize = 10, - FontWeight = FontWeight.SemiBold, - Foreground = Brushes.White, - }; - pctLabel.Measure(Size.Infinity); - Canvas.SetLeft(pctLabel, lx - pctLabel.DesiredSize.Width / 2); - Canvas.SetTop(pctLabel, ly - pctLabel.DesiredSize.Height / 2); - DonutCanvas.Children.Add(pctLabel); - } - - startAngle += sweepAngle; - } - - // Center text - var centerText = new TextBlock - { - Text = $"{activeCount}/{total}", - FontSize = 16, - FontWeight = FontWeight.Bold, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - }; - centerText.Measure(Size.Infinity); - Canvas.SetLeft(centerText, cx - centerText.DesiredSize.Width / 2); - Canvas.SetTop(centerText, cy - centerText.DesiredSize.Height / 2); - DonutCanvas.Children.Add(centerText); - - // Legend below - var legendY = cy + radius + 5; - DrawLegendItem(DonutCanvas, 4, legendY, ReadWriteColor, $"RW: {rwCount}"); - DrawLegendItem(DonutCanvas, 4, legendY + 16, ReadOnlyColor, $"RO: {roCount}"); - DrawLegendItem(DonutCanvas, 4, legendY + 32, OffColor, $"OFF: {offCount}"); - if (errCount > 0) DrawLegendItem(DonutCanvas, 4, legendY + 48, ErrorColor, $"Err: {errCount}"); - } - - private void ShowDonutPopup(QueryStoreState state) - { - var dbs = _states.Where(s => s.State == state).OrderBy(s => s.DatabaseName).ToList(); - var stateLabel = state switch - { - QueryStoreState.ReadWrite => "Read Write", - QueryStoreState.ReadOnly => "Read Only", - QueryStoreState.Error => "Error", - _ => "OFF" - }; - - var stack = new StackPanel { Spacing = 2, Margin = new Thickness(4) }; - stack.Children.Add(new TextBlock - { - Text = $"{stateLabel} ({dbs.Count})", - FontSize = 12, - FontWeight = FontWeight.Bold, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Margin = new Thickness(0, 0, 0, 4) - }); - - foreach (var db in dbs) - { - var text = db.DatabaseName; - if (db.State == QueryStoreState.Error && !string.IsNullOrEmpty(db.ErrorMessage)) - text += $" — {db.ErrorMessage}"; - stack.Children.Add(new TextBlock - { - Text = text, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - TextWrapping = Avalonia.Media.TextWrapping.Wrap, - }); - } - - var popup = new Popup - { - PlacementTarget = DonutCanvas, - Placement = PlacementMode.Pointer, - IsLightDismissEnabled = true, - Child = new Border - { - Background = this.FindResource("BackgroundLightBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#22252D")), - BorderBrush = this.FindResource("BorderBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#3A3D45")), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(10), - MinWidth = 180, - MaxHeight = 300, - Child = new ScrollViewer { Content = stack } - } - }; - - // Add popup to the visual tree temporarily - if (DonutCanvas.Parent is Panel parentPanel) - { - parentPanel.Children.Add(popup); - popup.IsOpen = true; - popup.Closed += (_, _) => parentPanel.Children.Remove(popup); - } - } - - private void DrawLegendItem(Canvas canvas, double x, double y, Color color, string text) - { - if (y + 14 > canvas.Bounds.Height) return; - var rect = new Rectangle { Width = 10, Height = 10, Fill = new SolidColorBrush(color) }; - Canvas.SetLeft(rect, x); - Canvas.SetTop(rect, y); - canvas.Children.Add(rect); - - var tb = new TextBlock - { - Text = text, - FontSize = 11, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")) - }; - Canvas.SetLeft(tb, x + 14); - Canvas.SetTop(tb, y - 1); - canvas.Children.Add(tb); - } - - private Path CreateArcPath(double cx, double cy, double outerR, double innerR, - double startAngle, double endAngle, Color fill) - { - var largeArc = (endAngle - startAngle) > Math.PI; - var outerStart = new Point(cx + outerR * Math.Cos(startAngle), cy + outerR * Math.Sin(startAngle)); - var outerEnd = new Point(cx + outerR * Math.Cos(endAngle), cy + outerR * Math.Sin(endAngle)); - var innerStart = new Point(cx + innerR * Math.Cos(endAngle), cy + innerR * Math.Sin(endAngle)); - var innerEnd = new Point(cx + innerR * Math.Cos(startAngle), cy + innerR * Math.Sin(startAngle)); - - var fig = new PathFigure { StartPoint = outerStart, IsClosed = true }; - fig.Segments!.Add(new ArcSegment - { - Point = outerEnd, - Size = new Size(outerR, outerR), - IsLargeArc = largeArc, - SweepDirection = SweepDirection.Clockwise - }); - fig.Segments.Add(new LineSegment { Point = innerStart }); - fig.Segments.Add(new ArcSegment - { - Point = innerEnd, - Size = new Size(innerR, innerR), - IsLargeArc = largeArc, - SweepDirection = SweepDirection.CounterClockwise - }); - - var geo = new PathGeometry(); - geo.Figures!.Add(fig); - return new Path { Data = geo, Fill = new SolidColorBrush(fill) }; - } - // ── Time Slicer (delegates to TimeRangeSlicerControl) ────────────────── private async void OnSlicerRangeChanged(object? sender, TimeRangeChangedEventArgs e) @@ -458,366 +247,4 @@ private void ClearRefreshError() ToolTip.SetTip(RefreshErrorBadge, null); } - // ── Wait Stats Chart (stacked by database) ────────────────────────────── - - private void DrawWaitStatsChart() - { - WaitStatsCanvas.Children.Clear(); - if (_waitSlices.Count == 0) - { - var msg = new TextBlock - { - Text = _supportsWaitStats ? "No wait stats data" : "Wait stats not supported (SQL 2017+ required)", - FontSize = 10, - Foreground = this.FindResource("ForegroundMutedBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#B0B6C0")), - TextWrapping = Avalonia.Media.TextWrapping.Wrap, - }; - Canvas.SetLeft(msg, 10); - Canvas.SetTop(msg, 20); - WaitStatsCanvas.Children.Add(msg); - return; - } - - if (_dbColorMap.Count == 0) return; // not yet initialized by DrawBarCards - - var w = WaitStatsBorder.Bounds.Width; - var h = WaitStatsBorder.Bounds.Height; - if (w < 10 || h < 10) return; - - const double paddingTop = 4; - const double paddingBottom = 16; - var chartH = h - paddingTop - paddingBottom; - if (chartH <= 0) return; - - // Consolidate: sum ALL wait ratios per database per hour (ignore wait category) - var dbHourData = _waitSlices - .GroupBy(s => new { s.DatabaseName, s.IntervalStartUtc }) - .Select(g => (Db: g.Key.DatabaseName, Hour: g.Key.IntervalStartUtc, Total: g.Sum(x => x.WaitAmountHours))) - .ToList(); - - if (dbHourData.Count == 0) return; - - // Build complete hourly timeline - var allHours = dbHourData.Select(x => x.Hour).Distinct().OrderBy(x => x).ToList(); - var n = allHours.Count; - if (n == 0) return; - - // Use the same top-N databases + Others as the bar cards - var topDbs = _dbColorMap.Keys.ToList(); - - // Compute per-hour totals for each database group - var hourLookup = dbHourData - .GroupBy(x => x.Hour) - .ToDictionary(g => g.Key, g => g.ToList()); - - // Find max stacked total for Y scaling - double maxTotal = 0; - double totalWaitSum = 0; - int bucketsWithData = 0; - foreach (var hour in allHours) - { - if (!hourLookup.TryGetValue(hour, out var items)) continue; - var total = items.Sum(x => x.Total); - if (total > maxTotal) maxTotal = total; - totalWaitSum += total; - bucketsWithData++; - } - if (maxTotal <= 0) maxTotal = 1; - - var stepX = w / n; - var barGap = Math.Min(2.0, Math.Max(0.5, stepX * 0.1)); - - for (int i = 0; i < n; i++) - { - var hour = allHours[i]; - if (!hourLookup.TryGetValue(hour, out var items)) continue; - - // Aggregate per database - var dbTotals = new Dictionary(); - double othersTotal = 0; - foreach (var item in items) - { - if (topDbs.Contains(item.Db)) - dbTotals[item.Db] = dbTotals.GetValueOrDefault(item.Db) + item.Total; - else - othersTotal += item.Total; - } - - double y = paddingTop + chartH; // bottom - var x = i * stepX; - - // Draw stacked bars: top databases first, then others - foreach (var db in topDbs) - { - var val = dbTotals.GetValueOrDefault(db); - if (val <= 0) continue; - var segH = (val / maxTotal) * chartH; - y -= segH; - - var color = _dbColorMap.GetValueOrDefault(db, OthersColor); - var rect = new Rectangle - { - Width = Math.Max(1, stepX - barGap), - Height = Math.Max(0.5, segH), - Fill = new SolidColorBrush(color), - }; - Canvas.SetLeft(rect, x); - Canvas.SetTop(rect, y); - WaitStatsCanvas.Children.Add(rect); - - ToolTip.SetTip(rect, $"{db}: {WaitRatioFormatter.Format(val)}"); - ToolTip.SetShowDelay(rect, 200); - } - - if (othersTotal > 0) - { - var segH = (othersTotal / maxTotal) * chartH; - y -= segH; - var rect = new Rectangle - { - Width = Math.Max(1, stepX - barGap), - Height = Math.Max(0.5, segH), - Fill = new SolidColorBrush(OthersColor), - }; - Canvas.SetLeft(rect, x); - Canvas.SetTop(rect, y); - WaitStatsCanvas.Children.Add(rect); - ToolTip.SetTip(rect, $"Others: {WaitRatioFormatter.Format(othersTotal)}"); - ToolTip.SetShowDelay(rect, 200); - } - } - - // X-axis labels at day boundaries - var labelBrush = new SolidColorBrush(Color.Parse("#E4E6EB")); - for (int i = 0; i < n; i++) - { - if (allHours[i].Hour == 0) - { - var xDay = i * stepX; - WaitStatsCanvas.Children.Add(new Line - { - StartPoint = new Point(xDay, paddingTop), - EndPoint = new Point(xDay, paddingTop + chartH), - Stroke = labelBrush, - StrokeThickness = 1, - StrokeDashArray = [4, 4], - Opacity = 0.3, - }); - var tb = new TextBlock - { - Text = TimeDisplayHelper.FormatForDisplay(allHours[i], "MM/dd"), - FontSize = 8, - Foreground = labelBrush, - }; - Canvas.SetLeft(tb, xDay + 2); - Canvas.SetTop(tb, h - paddingBottom + 1); - WaitStatsCanvas.Children.Add(tb); - } - } - - // ── Horizontal dashed average line ───────────────────────────────── - if (bucketsWithData > 0) - { - var avgWait = totalWaitSum / bucketsWithData; - if (avgWait > 0 && avgWait <= maxTotal) - { - var avgY = paddingTop + chartH - (avgWait / maxTotal) * chartH; - var dashBrush = new SolidColorBrush(Color.Parse("#E4E6EB")); - var avgLine = new Line - { - StartPoint = new Point(0, avgY), - EndPoint = new Point(w, avgY), - Stroke = dashBrush, - StrokeThickness = 1, - StrokeDashArray = [6, 3], - Opacity = 0.7, - }; - WaitStatsCanvas.Children.Add(avgLine); - - var avgLabel = new Border - { - Background = new SolidColorBrush(Color.Parse("#B0D0D0D0")), - CornerRadius = new CornerRadius(3), - Padding = new Thickness(4, 1), - Child = new TextBlock - { - Text = $"avg:{WaitRatioFormatter.Format(avgWait)}", - FontSize = 10, - Foreground = Brushes.Black, - }, - }; - Canvas.SetLeft(avgLabel, 2); - Canvas.SetTop(avgLabel, Math.Max(0, avgY - 16)); - WaitStatsCanvas.Children.Add(avgLabel); - } - } - } - - // ── Bar Chart Cards ───────────────────────────────────────────────────── - - /// Unified color map: same database → same color across all cards. - private Dictionary _dbColorMap = new(); - - private void DrawBarCards() - { - // Build a single color map based on top-N by total CPU (union across all databases) - _dbColorMap.Clear(); - var ranked = _metrics - .OrderByDescending(m => m.TotalCpu) - .Select(m => m.DatabaseName) - .ToList(); - var topDbs = ranked.Take(_topN).ToList(); - for (int i = 0; i < topDbs.Count && i < _palette.Length; i++) - _dbColorMap[topDbs[i]] = _palette[i]; - - DrawMetricRow(TotalMetricsGrid, isTotal: true, topDbs); - DrawMetricRow(AvgMetricsGrid, isTotal: false, topDbs); - } - - private void DrawMetricRow(Grid grid, bool isTotal, List topDbs) - { - grid.Children.Clear(); - grid.ColumnDefinitions.Clear(); - - var metricNames = isTotal - ? new[] { "Total CPU", "Total Duration", "Executions", "Total Reads", "Total Writes", "Total Physical Reads", "Total Memory" } - : new[] { "Avg CPU", "Avg Duration", "Executions", "Avg Reads", "Avg Writes", "Avg Physical Reads", "Avg Memory" }; - - for (int i = 0; i < metricNames.Length; i++) - grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Star)); - - for (int mi = 0; mi < metricNames.Length; mi++) - { - var card = CreateBarCard(metricNames[mi], mi, isTotal, topDbs, _dbColorMap); - Grid.SetColumn(card, mi); - card.Margin = new Thickness(mi == 0 ? 0 : 5, 0, mi == metricNames.Length - 1 ? 0 : 5, 0); - grid.Children.Add(card); - } - } - - private Border CreateBarCard(string title, int metricIndex, bool isTotal, - List topDbs, Dictionary dbColors) - { - var border = new Border - { - Background = this.FindResource("BackgroundLightBrush") as IBrush ?? new SolidColorBrush(Color.Parse("#22252D")), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6), - ClipToBounds = true - }; - - var stack = new StackPanel { Spacing = 4 }; - - // Title - stack.Children.Add(new TextBlock - { - Text = title, - FontSize = 11, - FontWeight = FontWeight.SemiBold, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center - }); - - // Build bar data - var bars = new List<(string db, double value, Color color)>(); - double othersValue = 0; - - foreach (var m in _metrics) - { - var val = GetMetricValue(m, metricIndex, isTotal); - if (topDbs.Contains(m.DatabaseName)) - bars.Add((m.DatabaseName, val, dbColors.GetValueOrDefault(m.DatabaseName, OthersColor))); - else - othersValue += val; - } - - if (othersValue > 0) - bars.Add(("Others", othersValue, OthersColor)); - - var maxVal = bars.Count > 0 ? bars.Max(b => b.value) : 1; - if (maxVal <= 0) maxVal = 1; - - foreach (var (db, value, color) in bars.OrderByDescending(b => b.value)) - { - var barContainer = new Border - { - Height = 22, - CornerRadius = new CornerRadius(3), - Background = new SolidColorBrush(color), - ClipToBounds = true, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch, - Width = double.NaN, - Margin = new Thickness(0, 1) - }; - - // Determine font color based on luminance - var luminance = 0.299 * color.R + 0.587 * color.G + 0.114 * color.B; - var fontColor = luminance > 128 ? Colors.Black : Colors.White; - - var barText = new TextBlock - { - Text = db.Length > 15 ? db[..12] + "..." : db, - FontSize = 10, - Foreground = new SolidColorBrush(fontColor), - VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, - Margin = new Thickness(4, 0), - TextTrimming = TextTrimming.CharacterEllipsis - }; - - barContainer.Child = barText; - ToolTip.SetTip(barContainer, $"{db}: {value:N0}"); - - // Context menu for drill-down - if (db != "Others") - { - var menu = new ContextMenu(); - var menuItem = new MenuItem { Header = "Drill Down to DB Query Store" }; - var dbName = db; - menuItem.Click += (_, _) => DrillDownRequested?.Invoke(this, new DrillDownEventArgs(dbName, _slicerStartUtc, _slicerEndUtc)); - menu.Items.Add(menuItem); - barContainer.ContextMenu = menu; - } - - // Scale width by ratio via a wrapping grid - var barGrid = new Grid(); - barGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(value / maxVal, GridUnitType.Star))); - barGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1 - value / maxVal, GridUnitType.Star))); - Grid.SetColumn(barContainer, 0); - barGrid.Children.Add(barContainer); - - stack.Children.Add(barGrid); - } - - border.Child = stack; - return border; - } - - private static double GetMetricValue(DatabaseMetrics m, int metricIndex, bool isTotal) - { - if (isTotal) - { - return metricIndex switch - { - 0 => m.TotalCpu, - 1 => m.TotalDuration, - 2 => m.TotalExecutions, - 3 => m.TotalReads, - 4 => m.TotalWrites, - 5 => m.TotalPhysicalReads, - 6 => m.TotalMemory, - _ => 0 - }; - } - return metricIndex switch - { - 0 => m.AvgCpu, - 1 => m.AvgDuration, - 2 => m.TotalExecutions, // Executions is the same - 3 => m.AvgReads, - 4 => m.AvgWrites, - 5 => m.AvgPhysicalReads, - 6 => m.AvgMemory, - _ => 0 - }; - } } From 0bb7947d984286d5d38dc84da7b7dc7840b65955 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:07:05 -0400 Subject: [PATCH 08/16] Extract shared DdlScripter to Core (dedupe drifted copies) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CREATE INDEX / CREATE TABLE scripting was duplicated in PlanViewerControl.Schema.cs and QuerySessionControl.Schema.cs, and the two copies had drifted: the query-session copy used a BracketName() helper for safe identifier quoting (and richer comments) while the plan-viewer copy used naive [{name}] interpolation that would double-bracket an already bracketed identifier. Consolidate onto the query-session (more-correct) version as a single, unit-testable Core DdlScripter and point both call sites at it. The plan viewer's schema scripting now also gets safe identifier bracketing — identical output for normal names, correct for already-bracketed ones. Adds DdlScripterTests covering clustered PK, nonclustered w/ include+ filter+options, columnstore, the bracket-safety behavior, and table/column + computed-column output. 77 -> 85 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controls/PlanViewerControl.Schema.cs | 186 +------------ .../Controls/QuerySessionControl.Schema.cs | 232 +---------------- src/PlanViewer.Core/Services/DdlScripter.cs | 245 ++++++++++++++++++ .../PlanViewer.Core.Tests/DdlScripterTests.cs | 137 ++++++++++ 4 files changed, 386 insertions(+), 414 deletions(-) create mode 100644 src/PlanViewer.Core/Services/DdlScripter.cs create mode 100644 tests/PlanViewer.Core.Tests/DdlScripterTests.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs index a765092..5f55059 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs @@ -54,7 +54,7 @@ private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" }; showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName, - async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); + async cs => DdlScripter.FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName))); menu.Items.Add(showIndexes); var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" }; @@ -63,7 +63,7 @@ private void AddSchemaMenuItems(ContextMenu menu, PlanNode node) { var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName); var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName); - return FormatColumns(objectName, columns, indexes); + return DdlScripter.FormatColumns(objectName, columns, indexes); }); menu.Items.Add(showTableDef); @@ -162,186 +162,4 @@ private void ShowSchemaResult(string title, string content) } } - private static string FormatIndexes(string objectName, IReadOnlyList indexes) - { - if (indexes.Count == 0) - return $"-- No indexes found on {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"-- Indexes on {objectName}"); - sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows"); - sb.AppendLine(); - - foreach (var ix in indexes) - { - if (ix.IsDisabled) - sb.AppendLine("-- ** DISABLED **"); - - sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}"); - - var withOptions = BuildWithOptions(ix); - var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null - ? $"ON [{ix.PartitionScheme}]([{ix.PartitionColumn}])" - : null; - - if (ix.IsPrimaryKey) - { - var clustered = IsClusteredType(ix) ? "CLUSTERED" : "NONCLUSTERED"; - sb.AppendLine($"ALTER TABLE {objectName}"); - sb.AppendLine($"ADD CONSTRAINT [{ix.IndexName}]"); - sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})"); - if (withOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", withOptions)})"); - } - if (onPartition != null) - { - sb.AppendLine(); - sb.Append($" {onPartition}"); - } - sb.AppendLine(";"); - } - else if (IsColumnstore(ix)) - { - var clustered = ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) - ? "NONCLUSTERED " : "CLUSTERED "; - sb.Append($"CREATE {clustered}COLUMNSTORE INDEX [{ix.IndexName}]"); - sb.AppendLine($" ON {objectName}"); - if (ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrEmpty(ix.KeyColumns)) - sb.AppendLine($"({ix.KeyColumns})"); - var csOptions = BuildColumnstoreWithOptions(ix); - if (csOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", csOptions)})"); - if (onPartition != null) - sb.AppendLine(onPartition); - TrimTrailingNewline(sb); - sb.AppendLine(";"); - } - else - { - var unique = ix.IsUnique ? "UNIQUE " : ""; - var clustered = IsClusteredType(ix) ? "CLUSTERED " : "NONCLUSTERED "; - sb.Append($"CREATE {unique}{clustered}INDEX [{ix.IndexName}]"); - sb.AppendLine($" ON {objectName}"); - sb.AppendLine($"({ix.KeyColumns})"); - if (!string.IsNullOrEmpty(ix.IncludeColumns)) - sb.AppendLine($"INCLUDE ({ix.IncludeColumns})"); - if (!string.IsNullOrEmpty(ix.FilterDefinition)) - sb.AppendLine($"WHERE {ix.FilterDefinition}"); - if (withOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", withOptions)})"); - if (onPartition != null) - sb.AppendLine(onPartition); - TrimTrailingNewline(sb); - sb.AppendLine(";"); - } - - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes) - { - if (columns.Count == 0) - return $"-- No columns found for {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"CREATE TABLE {objectName}"); - sb.AppendLine("("); - - var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); - - for (int i = 0; i < columns.Count; i++) - { - var col = columns[i]; - var isLast = i == columns.Count - 1; - - sb.Append($" [{col.ColumnName}] "); - - if (col.IsComputed && col.ComputedDefinition != null) - { - sb.Append($"AS {col.ComputedDefinition}"); - } - else - { - sb.Append(col.DataType); - if (col.IsIdentity) - sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})"); - sb.Append(col.IsNullable ? " NULL" : " NOT NULL"); - if (col.DefaultValue != null) - sb.Append($" DEFAULT {col.DefaultValue}"); - } - - sb.AppendLine(!isLast || pkIndex != null ? "," : ""); - } - - if (pkIndex != null) - { - var clustered = IsClusteredType(pkIndex) ? "CLUSTERED " : "NONCLUSTERED "; - sb.AppendLine($" CONSTRAINT [{pkIndex.IndexName}]"); - sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})"); - var pkOptions = BuildWithOptions(pkIndex); - if (pkOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", pkOptions)})"); - } - sb.AppendLine(); - } - - sb.Append(")"); - - var clusteredIx = indexes.FirstOrDefault(ix => IsClusteredType(ix) && !IsColumnstore(ix)); - if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null) - { - sb.AppendLine(); - sb.Append($"ON [{clusteredIx.PartitionScheme}]([{clusteredIx.PartitionColumn}])"); - } - - sb.AppendLine(";"); - return sb.ToString(); - } - - private static bool IsClusteredType(IndexInfo ix) => - ix.IndexType.Contains("CLUSTERED", StringComparison.OrdinalIgnoreCase) - && !ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase); - - private static bool IsColumnstore(IndexInfo ix) => - ix.IndexType.Contains("COLUMNSTORE", StringComparison.OrdinalIgnoreCase); - - private static List BuildWithOptions(IndexInfo ix) - { - var options = new List(); - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - if (!ix.AllowRowLocks) - options.Add("ALLOW_ROW_LOCKS = OFF"); - if (!ix.AllowPageLocks) - options.Add("ALLOW_PAGE_LOCKS = OFF"); - if (!string.Equals(ix.DataCompression, "NONE", StringComparison.OrdinalIgnoreCase)) - options.Add($"DATA_COMPRESSION = {ix.DataCompression}"); - return options; - } - - private static List BuildColumnstoreWithOptions(IndexInfo ix) - { - var options = new List(); - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - return options; - } - - private static void TrimTrailingNewline(System.Text.StringBuilder sb) - { - if (sb.Length > 0 && sb[sb.Length - 1] == '\n') sb.Length--; - if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--; - } } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs index 6f3ab1c..492ca1d 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs @@ -46,14 +46,14 @@ private async Task ShowSchemaInfoAsync(SchemaInfoKind kind) { case SchemaInfoKind.Indexes: var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName); - content = FormatIndexes(objectName, indexes); + content = DdlScripter.FormatIndexes(objectName, indexes); tabLabel = $"Indexes — {objectName}"; break; case SchemaInfoKind.TableDefinition: var columns = await SchemaQueryService.FetchColumnsAsync(_connectionString, objectName); var tableIndexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName); - content = FormatColumns(objectName, columns, tableIndexes); + content = DdlScripter.FormatColumns(objectName, columns, tableIndexes); tabLabel = $"Table — {objectName}"; break; @@ -164,232 +164,4 @@ private void AddSchemaTab(string label, string content, bool isSql) SubTabControl.SelectedItem = tab; } - private static string FormatIndexes(string objectName, IReadOnlyList indexes) - { - if (indexes.Count == 0) - return $"-- No indexes found on {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"-- Indexes on {objectName}"); - sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows"); - sb.AppendLine(); - - foreach (var ix in indexes) - { - if (ix.IsDisabled) - sb.AppendLine("-- ** DISABLED **"); - - // Usage stats as a comment - sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}"); - - var withOptions = BuildWithOptions(ix); - - var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null - ? $"ON {BracketName(ix.PartitionScheme)}({BracketName(ix.PartitionColumn)})" - : null; - - if (ix.IsPrimaryKey) - { - var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) - && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) - ? "CLUSTERED" : "NONCLUSTERED"; - sb.AppendLine($"ALTER TABLE {objectName}"); - sb.AppendLine($"ADD CONSTRAINT {BracketName(ix.IndexName)}"); - sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})"); - if (withOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", withOptions)})"); - } - if (onPartition != null) - { - sb.AppendLine(); - sb.Append($" {onPartition}"); - } - sb.AppendLine(";"); - } - else if (IsColumnstore(ix)) - { - // Columnstore indexes: no key columns, no INCLUDE, no row/page lock or compression options - var clustered = ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) - ? "NONCLUSTERED " : "CLUSTERED "; - sb.Append($"CREATE {clustered}COLUMNSTORE INDEX {BracketName(ix.IndexName)}"); - sb.AppendLine($" ON {objectName}"); - - // Nonclustered columnstore can have a column list - if (ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrEmpty(ix.KeyColumns)) - { - sb.AppendLine($"({ix.KeyColumns})"); - } - - // Only emit non-default options that aren't inherent to columnstore - var csOptions = BuildColumnstoreWithOptions(ix); - if (csOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", csOptions)})"); - - if (onPartition != null) - sb.AppendLine(onPartition); - - // Remove trailing newline before semicolon - if (sb[sb.Length - 1] == '\n') sb.Length--; - if (sb[sb.Length - 1] == '\r') sb.Length--; - sb.AppendLine(";"); - } - else - { - var unique = ix.IsUnique ? "UNIQUE " : ""; - var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) - && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) - ? "CLUSTERED " : "NONCLUSTERED "; - sb.Append($"CREATE {unique}{clustered}INDEX {BracketName(ix.IndexName)}"); - sb.AppendLine($" ON {objectName}"); - sb.Append($"("); - sb.Append(ix.KeyColumns); - sb.AppendLine(")"); - - if (!string.IsNullOrEmpty(ix.IncludeColumns)) - sb.AppendLine($"INCLUDE ({ix.IncludeColumns})"); - - if (!string.IsNullOrEmpty(ix.FilterDefinition)) - sb.AppendLine($"WHERE {ix.FilterDefinition}"); - - if (withOptions.Count > 0) - sb.AppendLine($"WITH ({string.Join(", ", withOptions)})"); - - if (onPartition != null) - sb.AppendLine(onPartition); - - // Remove trailing newline before semicolon - if (sb[sb.Length - 1] == '\n') sb.Length--; - if (sb[sb.Length - 1] == '\r') sb.Length--; - sb.AppendLine(";"); - } - - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static bool IsColumnstore(IndexInfo ix) => - ix.IndexType.Contains("COLUMNSTORE", System.StringComparison.OrdinalIgnoreCase); - - private static List BuildWithOptions(IndexInfo ix) - { - var options = new List(); - - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - if (!ix.AllowRowLocks) - options.Add("ALLOW_ROW_LOCKS = OFF"); - if (!ix.AllowPageLocks) - options.Add("ALLOW_PAGE_LOCKS = OFF"); - if (!string.Equals(ix.DataCompression, "NONE", System.StringComparison.OrdinalIgnoreCase)) - options.Add($"DATA_COMPRESSION = {ix.DataCompression}"); - - return options; - } - - /// - /// For columnstore indexes, skip options that are inherent to the storage format - /// (row/page locks are always OFF, compression is always COLUMNSTORE). - /// Only emit fill factor and pad index if non-default. - /// - private static List BuildColumnstoreWithOptions(IndexInfo ix) - { - var options = new List(); - - if (ix.FillFactor > 0 && ix.FillFactor != 100) - options.Add($"FILLFACTOR = {ix.FillFactor}"); - if (ix.IsPadded) - options.Add("PAD_INDEX = ON"); - - return options; - } - - private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes) - { - if (columns.Count == 0) - return $"-- No columns found for {objectName}"; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"CREATE TABLE {objectName}"); - sb.AppendLine("("); - - for (int i = 0; i < columns.Count; i++) - { - var col = columns[i]; - var isLast = i == columns.Count - 1; - - sb.Append($" {BracketName(col.ColumnName)} "); - - if (col.IsComputed && col.ComputedDefinition != null) - { - sb.Append($"AS {col.ComputedDefinition}"); - } - else - { - sb.Append(col.DataType); - - if (col.IsIdentity) - sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})"); - - sb.Append(col.IsNullable ? " NULL" : " NOT NULL"); - - if (col.DefaultValue != null) - sb.Append($" DEFAULT {col.DefaultValue}"); - } - - // Check if we need a PK constraint after all columns - var pk = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); - var needsTrailingComma = !isLast || pk != null; - - sb.AppendLine(needsTrailingComma ? "," : ""); - } - - // Add PK constraint - var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); - if (pkIndex != null) - { - var clustered = pkIndex.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) - && !pkIndex.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) - ? "CLUSTERED " : "NONCLUSTERED "; - sb.AppendLine($" CONSTRAINT {BracketName(pkIndex.IndexName)}"); - sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})"); - var pkOptions = BuildWithOptions(pkIndex); - if (pkOptions.Count > 0) - { - sb.AppendLine(); - sb.Append($" WITH ({string.Join(", ", pkOptions)})"); - } - sb.AppendLine(); - } - - sb.Append(")"); - - // Add partition scheme from the clustered index (determines table storage) - var clusteredIx = indexes.FirstOrDefault(ix => - ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) - && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)); - if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null) - { - sb.AppendLine(); - sb.Append($"ON {BracketName(clusteredIx.PartitionScheme)}({BracketName(clusteredIx.PartitionColumn)})"); - } - - sb.AppendLine(";"); - - return sb.ToString(); - } - - private static string BracketName(string name) - { - // Already bracketed - if (name.StartsWith('[')) - return name; - return $"[{name}]"; - } } diff --git a/src/PlanViewer.Core/Services/DdlScripter.cs b/src/PlanViewer.Core/Services/DdlScripter.cs new file mode 100644 index 0000000..3405606 --- /dev/null +++ b/src/PlanViewer.Core/Services/DdlScripter.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PlanViewer.Core.Services; + +/// +/// Builds CREATE INDEX / CREATE TABLE DDL from collected schema metadata. +/// Shared by the desktop plan viewer and the query-session schema views so both +/// emit identical, safely-bracketed scripts. Previously duplicated in both +/// controls' code-behind, where the two copies had drifted (only one used +/// safe identifier bracketing); this is the more-correct version. +/// +public static class DdlScripter +{ + public static string FormatIndexes(string objectName, IReadOnlyList indexes) + { + if (indexes.Count == 0) + return $"-- No indexes found on {objectName}"; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"-- Indexes on {objectName}"); + sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows"); + sb.AppendLine(); + + foreach (var ix in indexes) + { + if (ix.IsDisabled) + sb.AppendLine("-- ** DISABLED **"); + + // Usage stats as a comment + sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}"); + + var withOptions = BuildWithOptions(ix); + + var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null + ? $"ON {BracketName(ix.PartitionScheme)}({BracketName(ix.PartitionColumn)})" + : null; + + if (ix.IsPrimaryKey) + { + var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) + && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) + ? "CLUSTERED" : "NONCLUSTERED"; + sb.AppendLine($"ALTER TABLE {objectName}"); + sb.AppendLine($"ADD CONSTRAINT {BracketName(ix.IndexName)}"); + sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})"); + if (withOptions.Count > 0) + { + sb.AppendLine(); + sb.Append($" WITH ({string.Join(", ", withOptions)})"); + } + if (onPartition != null) + { + sb.AppendLine(); + sb.Append($" {onPartition}"); + } + sb.AppendLine(";"); + } + else if (IsColumnstore(ix)) + { + // Columnstore indexes: no key columns, no INCLUDE, no row/page lock or compression options + var clustered = ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) + ? "NONCLUSTERED " : "CLUSTERED "; + sb.Append($"CREATE {clustered}COLUMNSTORE INDEX {BracketName(ix.IndexName)}"); + sb.AppendLine($" ON {objectName}"); + + // Nonclustered columnstore can have a column list + if (ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(ix.KeyColumns)) + { + sb.AppendLine($"({ix.KeyColumns})"); + } + + // Only emit non-default options that aren't inherent to columnstore + var csOptions = BuildColumnstoreWithOptions(ix); + if (csOptions.Count > 0) + sb.AppendLine($"WITH ({string.Join(", ", csOptions)})"); + + if (onPartition != null) + sb.AppendLine(onPartition); + + // Remove trailing newline before semicolon + if (sb[sb.Length - 1] == '\n') sb.Length--; + if (sb[sb.Length - 1] == '\r') sb.Length--; + sb.AppendLine(";"); + } + else + { + var unique = ix.IsUnique ? "UNIQUE " : ""; + var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) + && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) + ? "CLUSTERED " : "NONCLUSTERED "; + sb.Append($"CREATE {unique}{clustered}INDEX {BracketName(ix.IndexName)}"); + sb.AppendLine($" ON {objectName}"); + sb.Append($"("); + sb.Append(ix.KeyColumns); + sb.AppendLine(")"); + + if (!string.IsNullOrEmpty(ix.IncludeColumns)) + sb.AppendLine($"INCLUDE ({ix.IncludeColumns})"); + + if (!string.IsNullOrEmpty(ix.FilterDefinition)) + sb.AppendLine($"WHERE {ix.FilterDefinition}"); + + if (withOptions.Count > 0) + sb.AppendLine($"WITH ({string.Join(", ", withOptions)})"); + + if (onPartition != null) + sb.AppendLine(onPartition); + + // Remove trailing newline before semicolon + if (sb[sb.Length - 1] == '\n') sb.Length--; + if (sb[sb.Length - 1] == '\r') sb.Length--; + sb.AppendLine(";"); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static bool IsColumnstore(IndexInfo ix) => + ix.IndexType.Contains("COLUMNSTORE", System.StringComparison.OrdinalIgnoreCase); + + private static List BuildWithOptions(IndexInfo ix) + { + var options = new List(); + + if (ix.FillFactor > 0 && ix.FillFactor != 100) + options.Add($"FILLFACTOR = {ix.FillFactor}"); + if (ix.IsPadded) + options.Add("PAD_INDEX = ON"); + if (!ix.AllowRowLocks) + options.Add("ALLOW_ROW_LOCKS = OFF"); + if (!ix.AllowPageLocks) + options.Add("ALLOW_PAGE_LOCKS = OFF"); + if (!string.Equals(ix.DataCompression, "NONE", System.StringComparison.OrdinalIgnoreCase)) + options.Add($"DATA_COMPRESSION = {ix.DataCompression}"); + + return options; + } + + /// + /// For columnstore indexes, skip options that are inherent to the storage format + /// (row/page locks are always OFF, compression is always COLUMNSTORE). + /// Only emit fill factor and pad index if non-default. + /// + private static List BuildColumnstoreWithOptions(IndexInfo ix) + { + var options = new List(); + + if (ix.FillFactor > 0 && ix.FillFactor != 100) + options.Add($"FILLFACTOR = {ix.FillFactor}"); + if (ix.IsPadded) + options.Add("PAD_INDEX = ON"); + + return options; + } + + public static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes) + { + if (columns.Count == 0) + return $"-- No columns found for {objectName}"; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"CREATE TABLE {objectName}"); + sb.AppendLine("("); + + for (int i = 0; i < columns.Count; i++) + { + var col = columns[i]; + var isLast = i == columns.Count - 1; + + sb.Append($" {BracketName(col.ColumnName)} "); + + if (col.IsComputed && col.ComputedDefinition != null) + { + sb.Append($"AS {col.ComputedDefinition}"); + } + else + { + sb.Append(col.DataType); + + if (col.IsIdentity) + sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})"); + + sb.Append(col.IsNullable ? " NULL" : " NOT NULL"); + + if (col.DefaultValue != null) + sb.Append($" DEFAULT {col.DefaultValue}"); + } + + // Check if we need a PK constraint after all columns + var pk = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); + var needsTrailingComma = !isLast || pk != null; + + sb.AppendLine(needsTrailingComma ? "," : ""); + } + + // Add PK constraint + var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey); + if (pkIndex != null) + { + var clustered = pkIndex.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) + && !pkIndex.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase) + ? "CLUSTERED " : "NONCLUSTERED "; + sb.AppendLine($" CONSTRAINT {BracketName(pkIndex.IndexName)}"); + sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})"); + var pkOptions = BuildWithOptions(pkIndex); + if (pkOptions.Count > 0) + { + sb.AppendLine(); + sb.Append($" WITH ({string.Join(", ", pkOptions)})"); + } + sb.AppendLine(); + } + + sb.Append(")"); + + // Add partition scheme from the clustered index (determines table storage) + var clusteredIx = indexes.FirstOrDefault(ix => + ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase) + && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)); + if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null) + { + sb.AppendLine(); + sb.Append($"ON {BracketName(clusteredIx.PartitionScheme)}({BracketName(clusteredIx.PartitionColumn)})"); + } + + sb.AppendLine(";"); + + return sb.ToString(); + } + + private static string BracketName(string name) + { + // Already bracketed + if (name.StartsWith('[')) + return name; + return $"[{name}]"; + } +} diff --git a/tests/PlanViewer.Core.Tests/DdlScripterTests.cs b/tests/PlanViewer.Core.Tests/DdlScripterTests.cs new file mode 100644 index 0000000..5ab7531 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/DdlScripterTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public class DdlScripterTests +{ + private static IndexInfo Index( + string name, + string type, + string keyColumns, + bool isPrimaryKey = false, + bool isUnique = false, + string includeColumns = "", + string? filter = null, + string compression = "NONE", + int fillFactor = 0, + string? partitionScheme = null, + string? partitionColumn = null) => new() + { + IndexName = name, + IndexType = type, + IsUnique = isUnique, + IsPrimaryKey = isPrimaryKey, + KeyColumns = keyColumns, + IncludeColumns = includeColumns, + FilterDefinition = filter, + RowCount = 1000, + SizeMB = 1.5, + DataCompression = compression, + FillFactor = fillFactor, + PartitionScheme = partitionScheme, + PartitionColumn = partitionColumn + }; + + private static ColumnInfo Column( + string name, + string dataType, + bool nullable = false, + bool identity = false, + bool computed = false, + string? defaultValue = null, + string? computedDef = null) => new() + { + OrdinalPosition = 1, + ColumnName = name, + DataType = dataType, + IsNullable = nullable, + IsIdentity = identity, + IsComputed = computed, + DefaultValue = defaultValue, + ComputedDefinition = computedDef, + IdentitySeed = 1, + IdentityIncrement = 1 + }; + + [Fact] + public void FormatIndexes_NoIndexes_ReturnsComment() + { + var sql = DdlScripter.FormatIndexes("dbo.T", new List()); + Assert.Equal("-- No indexes found on dbo.T", sql); + } + + [Fact] + public void FormatIndexes_ClusteredPrimaryKey_EmitsAlterTableConstraint() + { + var sql = DdlScripter.FormatIndexes("dbo.T", new[] { Index("PK_T", "CLUSTERED", "Id", isPrimaryKey: true) }); + + Assert.Contains("ALTER TABLE dbo.T", sql); + Assert.Contains("ADD CONSTRAINT [PK_T]", sql); + Assert.Contains("PRIMARY KEY CLUSTERED (Id)", sql); + } + + [Fact] + public void FormatIndexes_NonClustered_EmitsIncludeFilterAndOptions() + { + var ix = Index("IX_T", "NONCLUSTERED", "A, B", includeColumns: "C, D", filter: "[A] > 0", fillFactor: 90); + var sql = DdlScripter.FormatIndexes("dbo.T", new[] { ix }); + + Assert.Contains("CREATE NONCLUSTERED INDEX [IX_T]", sql); + Assert.Contains("INCLUDE (C, D)", sql); + Assert.Contains("WHERE [A] > 0", sql); + Assert.Contains("WITH (FILLFACTOR = 90)", sql); + } + + [Fact] + public void FormatIndexes_Columnstore_EmitsColumnstoreCreate() + { + var sql = DdlScripter.FormatIndexes("dbo.T", new[] { Index("CCI_T", "CLUSTERED COLUMNSTORE", "") }); + Assert.Contains("CREATE CLUSTERED COLUMNSTORE INDEX [CCI_T]", sql); + } + + [Fact] + public void FormatIndexes_AlreadyBracketedName_IsNotDoubleBracketed() + { + // Canonical behavior: BracketName leaves an already-bracketed identifier alone. + var sql = DdlScripter.FormatIndexes("dbo.T", new[] { Index("[IX_T]", "NONCLUSTERED", "A") }); + + Assert.Contains("[IX_T]", sql); + Assert.DoesNotContain("[[IX_T]]", sql); + } + + [Fact] + public void FormatColumns_NoColumns_ReturnsComment() + { + var sql = DdlScripter.FormatColumns("dbo.T", new List(), new List()); + Assert.Equal("-- No columns found for dbo.T", sql); + } + + [Fact] + public void FormatColumns_TableWithColumnsAndPrimaryKey() + { + var cols = new[] + { + Column("Id", "int", identity: true), + Column("Name", "nvarchar(50)", nullable: true) + }; + var pk = Index("PK_T", "CLUSTERED", "Id", isPrimaryKey: true); + + var sql = DdlScripter.FormatColumns("dbo.T", cols, new[] { pk }); + + Assert.Contains("CREATE TABLE dbo.T", sql); + Assert.Contains("[Id] int IDENTITY(1, 1) NOT NULL", sql); + Assert.Contains("[Name] nvarchar(50) NULL", sql); + Assert.Contains("CONSTRAINT [PK_T]", sql); + Assert.Contains("PRIMARY KEY CLUSTERED (Id)", sql); + } + + [Fact] + public void FormatColumns_ComputedColumn_EmitsAsExpression() + { + var cols = new[] { Column("Total", "int", computed: true, computedDef: "([A] + [B])") }; + var sql = DdlScripter.FormatColumns("dbo.T", cols, new List()); + + Assert.Contains("[Total] AS ([A] + [B])", sql); + } +} From 3303fd695e81bdafa0dba1d03970d9a14e56e559 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:10:36 -0400 Subject: [PATCH 09/16] Split ShowPlanParser.ParseRelOp into focused helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParseRelOp was a single ~760-line method. Extract the two largest, self-contained sections into private helpers, leaving ParseRelOp as a ~130-line orchestrator (node init -> operator props -> RelOp-level props -> runtime info -> icon -> recurse children): - ParseOperatorProperties(node, relOpEl, physicalOpEl) — the operator- specific property parsing (the former `if (physicalOpEl != null)` block). - ParseRuntimeInformation(node, relOpEl) — actual-plan per-thread runtime counter aggregation and PerThreadStats. Pure mechanical extraction; the 85 parser/analyzer tests confirm identical output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/ShowPlanParser.RelOp.cs | 1047 +++++++++-------- 1 file changed, 527 insertions(+), 520 deletions(-) diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs index c475ec1..0842010 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs @@ -62,591 +62,613 @@ private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0) // Handle operator-specific element var physicalOpEl = GetOperatorElement(relOpEl); if (physicalOpEl != null) + ParseOperatorProperties(node, relOpEl, physicalOpEl); + + // Output columns + var outputList = relOpEl.Element(Ns + "OutputList"); + if (outputList != null) { - // Top N Sort — XML element is but PhysicalOp is "Sort" - if (physicalOpEl.Name.LocalName == "TopSort") - node.LogicalOp = "Top N Sort"; + 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("]", ""); + } - // 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); - } + // Warnings + node.Warnings = ParseWarnings(relOpEl); - // 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); - } - } + // 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); + } - // Hash keys for hash match operators - var hashKeysProbeEl = physicalOpEl.Element(Ns + "HashKeysProbe"); - if (hashKeysProbeEl != null) + // Wave 3.3: RunTimePartitionSummary (on RelOp) + var rtPartEl = relOpEl.Element(Ns + "RunTimePartitionSummary"); + if (rtPartEl != null) + { + var partAccEl = rtPartEl.Element(Ns + "PartitionsAccessed"); + if (partAccEl != null) { - var cols = hashKeysProbeEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - node.HashKeysProbe = string.Join(", ", cols); + 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; } - var hashKeysBuildEl = physicalOpEl.Element(Ns + "HashKeysBuild"); - if (hashKeysBuildEl != 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); + } + + ParseRuntimeInformation(node, relOpEl); + + // 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, depth + 1); + childNode.Parent = node; + node.Children.Add(childNode); + } + + return node; + } + + private static void ParseOperatorProperties(PlanNode node, XElement relOpEl, XElement physicalOpEl) + { + // 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 cols = hashKeysBuildEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) - .Where(s => !string.IsNullOrEmpty(s)); - node.HashKeysBuild = string.Join(", ", cols); + 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"; + // 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) + // 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")) { - foreach (var seekKeys in sp.Elements(Ns + "SeekKeys")) + // Each SeekKeys has Prefix, StartRange, EndRange with ScanType + foreach (var range in seekKeys.Elements()) { - // 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 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 { - 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]}"); - } + "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); + } + 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; + // 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; - } + // 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; + // 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; - } + // 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; - } + // 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; - } + // 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; - } + // 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"); + // 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"); + // GroupBy columns (Hash/Stream Aggregate) + node.GroupBy = ParseColumnList(physicalOpEl, "GroupBy"); - // Partition columns (Parallelism) - node.PartitionColumns = ParseColumnList(physicalOpEl, "PartitionColumns"); + // Partition columns (Parallelism) + node.PartitionColumns = ParseColumnList(physicalOpEl, "PartitionColumns"); - // Wave 2.6: Parallelism HashKeys - node.HashKeys = ParseColumnList(physicalOpEl, "HashKeys"); + // 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); + // 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) + // 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 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); + 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"; - } + // 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") + // 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 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 rangeCols = spoolSeek.Descendants(Ns + "RangeColumns") + .SelectMany(rc => rc.Elements(Ns + "ColumnReference")); - var keyColumns = new List(); - string? tblSchema = null; - string? tblName = null; + 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("]", ""); - } + 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; - } + 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); + // 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; - } + // 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; + // 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: 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: 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: 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: 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: 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: 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: 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) + // 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 tvfCols = tvfParamListEl.Elements(Ns + "ColumnReference") - .Select(c => FormatColumnRef(c)) + var tvfScalars = tvfParamListEl.Elements(Ns + "ScalarOperator") + .Select(s => s.Attribute("ScalarString")?.Value) .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); + var tvfScalarStr = string.Join(", ", tvfScalars); + if (!string.IsNullOrEmpty(tvfScalarStr)) + node.TvfParameters = tvfScalarStr; } + } - // XSD gap: TieColumns (Top operator) - node.TieColumns = ParseColumnList(physicalOpEl, "TieColumns"); - - // XSD gap: UDXName (Extension operator) - node.UdxName = physicalOpEl.Attribute("UDXName")?.Value; + // XSD gap: OriginalActionColumn (Update) + var origActionColEl = physicalOpEl.Element(Ns + "OriginalActionColumn")?.Element(Ns + "ColumnReference"); + if (origActionColEl != null) + node.OriginalActionColumn = FormatColumnRef(origActionColEl); - // XSD gap: Operator-level IndexedViewInfo - var opIvInfoEl = physicalOpEl.Element(Ns + "IndexedViewInfo"); - if (opIvInfoEl != null) + // XSD gap: Scalar UDF structured detection + foreach (var udfEl in ScopedDescendants(physicalOpEl, Ns + "UserDefinedFunction")) + { + var udfRef = new ScalarUdfReference { - 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) + 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) { - 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); - } + 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: 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); + // XSD gap: TieColumns (Top operator) + node.TieColumns = ParseColumnList(physicalOpEl, "TieColumns"); - // 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); - } + // XSD gap: UDXName (Extension operator) + node.UdxName = physicalOpEl.Attribute("UDXName")?.Value; - // UDX UsedUDXColumns — column references for CLR aggregate operators - var udxColsEl = physicalOpEl.Element(Ns + "UsedUDXColumns"); - if (udxColsEl != null) + // XSD gap: Operator-level IndexedViewInfo + var opIvInfoEl = physicalOpEl.Element(Ns + "IndexedViewInfo"); + if (opIvInfoEl != null) + { + foreach (var ivObjEl in opIvInfoEl.Elements(Ns + "Object")) { - 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; + 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); } } - // Output columns - var outputList = relOpEl.Element(Ns + "OutputList"); - if (outputList != null) + // XSD gap: NamedParameterList (IndexScan) + var namedParamListEl = physicalOpEl.Element(Ns + "NamedParameterList"); + if (namedParamListEl != null) { - var cols = outputList.Elements(Ns + "ColumnReference") - .Select(c => + foreach (var npEl in namedParamListEl.Elements(Ns + "NamedParameter")) + { + var np = new NamedParameterInfo { - 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("]", ""); + 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); + } } - // Warnings - node.Warnings = ParseWarnings(relOpEl); + // 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; - // 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); - } + // 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); - // Wave 3.3: RunTimePartitionSummary (on RelOp) - var rtPartEl = relOpEl.Element(Ns + "RunTimePartitionSummary"); - if (rtPartEl != null) + // ConstantScan Values — parse Values/Row/ScalarOperator children + var valuesEl = physicalOpEl.Element(Ns + "Values"); + if (valuesEl != null) { - var partAccEl = rtPartEl.Element(Ns + "PartitionsAccessed"); - if (partAccEl != null) + var rowParts = new List(); + foreach (var rowEl in valuesEl.Elements(Ns + "Row")) { - 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; + 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); } - // Wave 2.4: Per-operator memory grants (MemoryGrant on RelOp) - var memGrantEl = relOpEl.Element(Ns + "MemoryGrant"); - if (memGrantEl != null) + // UDX UsedUDXColumns — column references for CLR aggregate operators + var udxColsEl = physicalOpEl.Element(Ns + "UsedUDXColumns"); + if (udxColsEl != null) { - node.MemoryGrantKB = ParseLong(memGrantEl.Attribute("GrantedMemory")?.Value); - node.DesiredMemoryKB = ParseLong(memGrantEl.Attribute("DesiredMemory")?.Value); - node.MaxUsedMemoryKB = ParseLong(memGrantEl.Attribute("MaxUsedMemory")?.Value); + 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; } + } - // Runtime information (actual plan) + private static void ParseRuntimeInformation(PlanNode node, XElement relOpEl) + { var runtimeEl = relOpEl.Element(Ns + "RunTimeInformation"); if (runtimeEl != null) { @@ -756,21 +778,6 @@ private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0) }); } } - - // 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, depth + 1); - childNode.Parent = node; - node.Children.Add(childNode); - } - - return node; } private static XElement? GetOperatorElement(XElement relOpEl) From d3b3815e0a69e9566ba64f7ea6bc26c45722e70d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:20:46 -0400 Subject: [PATCH 10/16] Extract shared CLI helpers (CliConnectionResolver + PlanAnalysisRunner) AnalyzeCommand and QueryStoreCommand duplicated their connection/credential resolution, server-metadata fetch, the parse->analyze->score pipeline, and the json/text/both result-file writing. Several copies had drifted. - CliConnectionResolver: IsAzureSqlDb, BuildServerConnection (auth-type resolution + SQL-credential existence check), and the non-fatal FetchServerMetadataAsync. - PlanAnalysisRunner: Analyze (parse + analyze + score, branching on serverMetadata) and WriteResultFilesAsync (warningsOnly + json/text/both). Both commands now share these. querystore picks up AnalyzeCommand's fail-fast "no credential found" check (wrapped in the same graceful try/catch) instead of proceeding to a cryptic downstream SQL error. Offline analyze, live capture, and querystore behavior otherwise unchanged; verified the offline analyze path end to end on a sample plan. 85 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.Cli/Commands/AnalyzeCommand.cs | 82 ++----------------- .../Commands/CliConnectionResolver.cs | 73 +++++++++++++++++ .../Commands/PlanAnalysisRunner.cs | 61 ++++++++++++++ .../Commands/QueryStoreCommand.cs | 64 +++------------ 4 files changed, 154 insertions(+), 126 deletions(-) create mode 100644 src/PlanViewer.Cli/Commands/CliConnectionResolver.cs create mode 100644 src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index 2deb3fc..e517dae 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -229,9 +229,7 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo return; } - var plan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(plan, analyzerConfig); - BenefitScorer.Score(plan); + var plan = PlanAnalysisRunner.Analyze(planXml, analyzerConfig); if (plan.Batches.Count == 0) { @@ -295,7 +293,7 @@ private static async Task RunLiveAsync( { try { - var connection = BuildServerConnection(server, auth, trustCert, credentialService); + var connection = CliConnectionResolver.BuildServerConnection(server, auth, trustCert, credentialService); connectionString = connection.GetConnectionString(credentialService, database); } catch (Exception ex) @@ -374,20 +372,12 @@ private static async Task RunLiveAsync( var outDir = outputDir?.FullName ?? Directory.GetCurrentDirectory(); Directory.CreateDirectory(outDir); - var isAzure = IsAzureSqlDb(server); + var isAzure = CliConnectionResolver.IsAzureSqlDb(server); var planType = estimated ? "estimated" : "actual"; Console.Error.WriteLine($"Capturing {planType} plans from {server}/{database}"); // Fetch server metadata for Rule 38 (Standard Edition DOP limitation) - ServerMetadata? serverMetadata = null; - try - { - serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(connectionString, isAzure); - } - catch - { - // Non-fatal: analysis continues without server context - } + var serverMetadata = await CliConnectionResolver.FetchServerMetadataAsync(connectionString, server); Console.Error.WriteLine(); @@ -435,31 +425,11 @@ private static async Task RunLiveAsync( await File.WriteAllTextAsync(planPath, planXml); // Parse, analyze, map result - var plan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(plan, analyzerConfig, serverMetadata); - BenefitScorer.Score(plan); + var plan = PlanAnalysisRunner.Analyze(planXml, analyzerConfig, serverMetadata); var result = ResultMapper.Map(plan, $"{name}.sql"); - if (warningsOnly) - { - foreach (var stmt in result.Statements) - stmt.OperatorTree = null; - } - - if (outputFormat == "json" || outputFormat == "both") - { - var jsonOpts = compact ? CompactJsonOptions : JsonOptions; - var json = JsonSerializer.Serialize(result, jsonOpts); - var analysisPath = Path.Combine(outDir, $"{name}.analysis.json"); - await File.WriteAllTextAsync(analysisPath, json); - } - - if (outputFormat == "text" || outputFormat == "both") - { - var txtPath = Path.Combine(outDir, $"{name}.analysis.txt"); - using var writer = new StreamWriter(txtPath); - TextFormatter.WriteText(result, writer); - } + await PlanAnalysisRunner.WriteResultFilesAsync( + result, outDir, name, outputFormat, compact ? CompactJsonOptions : JsonOptions, warningsOnly); Console.Error.WriteLine($"OK ({sw.Elapsed.TotalSeconds:F1}s)"); } @@ -487,43 +457,5 @@ private static async Task RunLiveAsync( Environment.ExitCode = 1; } - private static ServerConnection BuildServerConnection( - string server, string? auth, bool trustCert, ICredentialService credentialService) - { - var authType = auth?.ToLowerInvariant() switch - { - "windows" => AuthenticationTypes.Windows, - "sql" => AuthenticationTypes.SqlServer, - "entra" => AuthenticationTypes.EntraMFA, - null => credentialService.CredentialExists(server) - ? AuthenticationTypes.SqlServer - : AuthenticationTypes.Windows, - _ => throw new ArgumentException($"Unknown auth type: {auth}. Use: windows, sql, entra") - }; - - if (authType == AuthenticationTypes.SqlServer && !credentialService.CredentialExists(server)) - { - Console.Error.WriteLine($"No credential found for {server}. Run: planview credential add {server} --user "); - Environment.ExitCode = 1; - throw new InvalidOperationException("No credentials configured"); - } - - return new ServerConnection - { - Id = server, - ServerName = server, - DisplayName = server, - AuthenticationType = authType, - TrustServerCertificate = trustCert, - EncryptMode = trustCert ? "Optional" : "Mandatory" - }; - } - - private static bool IsAzureSqlDb(string serverName) - { - return serverName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || - serverName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase); - } - #endregion } diff --git a/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs b/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs new file mode 100644 index 0000000..3893cd9 --- /dev/null +++ b/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Cli.Commands; + +/// +/// Shared connection/credential resolution for the live-capture CLI commands +/// (analyze --server and querystore), which previously duplicated this logic. +/// +public static class CliConnectionResolver +{ + public static bool IsAzureSqlDb(string serverName) => + serverName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || + serverName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase); + + /// + /// Builds a ServerConnection from the --auth flag and trust-cert setting, + /// resolving the auth type and validating that a stored credential exists for + /// SQL auth. On a missing credential it prints guidance, sets a failing exit + /// code, and throws so the caller can abort. + /// + public static ServerConnection BuildServerConnection( + string server, string? auth, bool trustCert, ICredentialService credentialService) + { + var authType = auth?.ToLowerInvariant() switch + { + "windows" => AuthenticationTypes.Windows, + "sql" => AuthenticationTypes.SqlServer, + "entra" => AuthenticationTypes.EntraMFA, + null => credentialService.CredentialExists(server) + ? AuthenticationTypes.SqlServer + : AuthenticationTypes.Windows, + _ => throw new ArgumentException($"Unknown auth type: {auth}. Use: windows, sql, entra") + }; + + if (authType == AuthenticationTypes.SqlServer && !credentialService.CredentialExists(server)) + { + Console.Error.WriteLine($"No credential found for {server}. Run: planview credential add {server} --user "); + Environment.ExitCode = 1; + throw new InvalidOperationException("No credentials configured"); + } + + return new ServerConnection + { + Id = server, + ServerName = server, + DisplayName = server, + AuthenticationType = authType, + TrustServerCertificate = trustCert, + EncryptMode = trustCert ? "Optional" : "Mandatory" + }; + } + + /// + /// Fetches server metadata for analysis (Rule 38 server context). Non-fatal: + /// returns null if the fetch fails so analysis can continue without it. + /// + public static async Task FetchServerMetadataAsync(string connectionString, string server) + { + try + { + return await ServerMetadataService.FetchServerMetadataAsync(connectionString, IsAzureSqlDb(server)); + } + catch + { + // Non-fatal: analysis continues without server context. + return null; + } + } +} diff --git a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs new file mode 100644 index 0000000..86cb250 --- /dev/null +++ b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Cli.Commands; + +/// +/// Shared plan-analysis pipeline and per-result file output for the CLI commands. +/// Previously the parse -> analyze -> score sequence and the json/text/both file +/// writing were duplicated across analyze (offline + live) and querystore. +/// +public static class PlanAnalysisRunner +{ + /// + /// Parses plan XML and runs the analysis + benefit-scoring pipeline. Pass + /// serverMetadata for live captures (enables server-context rules); pass null + /// for offline .sqlplan files. + /// + public static ParsedPlan Analyze(string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata = null) + { + var plan = ShowPlanParser.Parse(planXml); + if (serverMetadata != null) + PlanAnalyzer.Analyze(plan, config, serverMetadata); + else + PlanAnalyzer.Analyze(plan, config); + BenefitScorer.Score(plan); + return plan; + } + + /// + /// Writes {label}.analysis.json and/or {label}.analysis.txt into outDir per + /// outputFormat ("json", "text", or "both"), honoring warningsOnly (which + /// drops operator trees from the serialized output). + /// + public static async Task WriteResultFilesAsync( + AnalysisResult result, string outDir, string label, + string outputFormat, JsonSerializerOptions jsonOptions, bool warningsOnly) + { + if (warningsOnly) + { + foreach (var stmt in result.Statements) + stmt.OperatorTree = null; + } + + if (outputFormat == "json" || outputFormat == "both") + { + var json = JsonSerializer.Serialize(result, jsonOptions); + await File.WriteAllTextAsync(Path.Combine(outDir, $"{label}.analysis.json"), json); + } + + if (outputFormat == "text" || outputFormat == "both") + { + var txtPath = Path.Combine(outDir, $"{label}.analysis.txt"); + using var writer = new StreamWriter(txtPath); + TextFormatter.WriteText(result, writer); + } + } +} diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index 5908ed9..5807570 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -235,25 +235,17 @@ public static Command Create(ICredentialService? credentialService = null) } else if (credentialService != null) { - var authType = auth?.ToLowerInvariant() switch + try { - "windows" => AuthenticationTypes.Windows, - "sql" => AuthenticationTypes.SqlServer, - "entra" => AuthenticationTypes.EntraMFA, - null => credentialService.CredentialExists(server) - ? AuthenticationTypes.SqlServer - : AuthenticationTypes.Windows, - _ => throw new ArgumentException($"Unknown auth type: {auth}") - }; - - var conn = new ServerConnection + var conn = CliConnectionResolver.BuildServerConnection(server, auth, trustCert, credentialService); + connectionString = conn.GetConnectionString(credentialService, database); + } + catch (Exception ex) { - Id = server, ServerName = server, DisplayName = server, - AuthenticationType = authType, - TrustServerCertificate = trustCert, - EncryptMode = trustCert ? "Optional" : "Mandatory" - }; - connectionString = conn.GetConnectionString(credentialService, database); + Console.Error.WriteLine(ex.Message); + Environment.ExitCode = 1; + return; + } } else { @@ -315,17 +307,7 @@ private static async Task RunAsync( Console.Error.WriteLine(); // Fetch server metadata for Rule 38 (Standard Edition DOP limitation) - ServerMetadata? serverMetadata = null; - try - { - var isAzure = server.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || - server.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase); - serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(connectionString, isAzure); - } - catch - { - // Non-fatal: analysis continues without server context - } + var serverMetadata = await CliConnectionResolver.FetchServerMetadataAsync(connectionString, server); // Resolve output directory var outDir = outputDir?.FullName ?? Directory.GetCurrentDirectory(); @@ -355,31 +337,11 @@ private static async Task RunAsync( await File.WriteAllTextAsync(planPath, qsPlan.PlanXml); // Parse, analyze, map - var plan = ShowPlanParser.Parse(qsPlan.PlanXml); - PlanAnalyzer.Analyze(plan, analyzerConfig, serverMetadata); - BenefitScorer.Score(plan); + var plan = PlanAnalysisRunner.Analyze(qsPlan.PlanXml, analyzerConfig, serverMetadata); var result = ResultMapper.Map(plan, $"{label}.sqlplan"); - if (warningsOnly) - { - foreach (var stmt in result.Statements) - stmt.OperatorTree = null; - } - - // Write analysis files - if (outputFormat == "json" || outputFormat == "both") - { - var jsonOpts = compact ? CompactJsonOptions : JsonOptions; - var json = JsonSerializer.Serialize(result, jsonOpts); - await File.WriteAllTextAsync(Path.Combine(outDir, $"{label}.analysis.json"), json); - } - - if (outputFormat == "text" || outputFormat == "both") - { - var txtPath = Path.Combine(outDir, $"{label}.analysis.txt"); - using var writer = new StreamWriter(txtPath); - TextFormatter.WriteText(result, writer); - } + await PlanAnalysisRunner.WriteResultFilesAsync( + result, outDir, label, outputFormat, compact ? CompactJsonOptions : JsonOptions, warningsOnly); var warnings = result.Summary.TotalWarnings; var critical = result.Summary.CriticalWarnings; From ad7c3de88039fed2116278d0aec14c6bca14808c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:24:43 -0400 Subject: [PATCH 11/16] Add golden-master warning characterization test for PlanAnalyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshots the analyzer's complete warning output (type, severity, and full message text, in order) across all 37 committed test plans into WarningBaseline.txt. This locks down current behavior so the upcoming extraction of AnalyzeNode/AnalyzeStatement's inline rules into methods can be proven behavior-preserving — any drift in the warning set fails the test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PlanViewer.Core.Tests/WarningBaseline.txt | 321 ++++++++++++++++++ .../WarningCharacterizationTests.cs | 58 ++++ 2 files changed, 379 insertions(+) create mode 100644 tests/PlanViewer.Core.Tests/WarningBaseline.txt create mode 100644 tests/PlanViewer.Core.Tests/WarningCharacterizationTests.cs diff --git a/tests/PlanViewer.Core.Tests/WarningBaseline.txt b/tests/PlanViewer.Core.Tests/WarningBaseline.txt new file mode 100644 index 0000000..5640f74 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/WarningBaseline.txt @@ -0,0 +1,321 @@ +### case_predicate_plan.sqlplan +Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 2 ms across 9 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 1 ms across 151 waits. +Wait: LATCH_EX | Info | LATCH_EX Observed 1 ms across 7 waits. +Non-SARGable Predicate | Warning | CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.\nPredicate: [StackOverflow2013].[dbo].[Users].[DisplayName] as [u].[DisplayName]=CASE WHEN getdate()(100000) +Eager Index Spool | Critical | SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely.\n\nCreate this index:\nCREATE INDEX [UserId] ON dbo.Badges (UserId) INCLUDE (Name); +Parallel Skew | Warning | Thread 3 processed 100% of rows (8,042,005/8,042,005). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 2 column(s): Badges.Name, Badges.UserId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### eager_table_spool_plan.sqlplan +Data Type Mismatch | Warning | Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column. +Filter Operator | Warning | Filter operator discarding rows late in the plan.\nPredicate: [Act1007]<>(1) +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]>[@hkey] AND [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]<=[@bmax] + +### excellent-parallel-spill.sqlplan +Wait: PAGELATCH_UP | Info | PAGELATCH_UP Observed 3 ms across 50 waits. +Wait: PAGELATCH_EX | Info | PAGELATCH_EX Observed 17 ms across 505 waits. +Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 43 ms across 22 waits. Effective latency: 2.0 ms per wait. +Wait: ASYNC_NETWORK_IO | Info | ASYNC_NETWORK_IO Observed 50 ms across 1 wait. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 552 ms across 50 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 871 ms across 1,148,085 waits. +Wait: PAGEIOLATCH_EX | Warning | PAGEIOLATCH_EX Observed 40,801 ms across 347,915 waits. Effective latency: 117 µs per wait. +Wait: CXPACKET | Critical | CXPACKET Observed 2,238,553 ms across 14,203 waits. +Spill to TempDb | Warning | Spill level 0, 1 thread(s) +Exchange Spill | Warning | Exchange spill — 4 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. Operator time: 95,717ms (42% of statement). +Expensive Operator | Critical | Merge Join took 129,365ms (57.3% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Spill to TempDb | Warning | Spill level 0, 4 thread(s) +Exchange Spill | Critical | Exchange spill — 5,786,565 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. Operator time: 114,401ms (51% of statement). +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 14 columns. A nonclustered rowstore index isn't a great fit for wide outputs, but if this is an analytical or aggregate-style query, a columnstore index (CCI or NCCI) can scan the same data far more cheaply — column count doesn't penalize columnstore the way it does rowstore indexes. +Spill to TempDb | Warning | Spill level 0, 1 thread(s) +Exchange Spill | Warning | Exchange spill — 8,890 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. Operator time: 14,755ms (7% of statement). +Parallel Skew | Warning | Thread 1 processed 100% of rows (1,531,307/1,531,307). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Parallel Skew | Warning | Thread 1 processed 100% of rows (1,531,307/1,531,307). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[CreationDate] as [u].[CreationDate]>'2011-09-01 00:00:00.000' AND [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>(100) +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(1) + +### exchange_spill_plan.sqlplan +Exchange Spill | Critical | Exchange spill — 5,000,000 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. Operator time: 10,000ms (12% of statement). +Expensive Operator | Critical | Sort took 65,000ms (81.2% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Test.Col1. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### implicit_convert_seek_plan.sqlplan +Implicit Conversion | Critical | Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. Seek Plan: CONVERT_IMPLICIT(nvarchar(40),[TestDB].[dbo].[Users].[DisplayName],0)=[@d] +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Users.Id. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### isnull_plan.sqlplan +Serial Plan | Warning | Query running serially: MAXDOP is set to 1 using a query hint. +Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 500 ms across 405 waits. Effective latency: 1.2 ms per wait. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 426 ms across 1,409 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 96 ms across 255,724 waits. +Non-SARGable Predicate | Warning | ISNULL/COALESCE wrapping a column prevents an index seek. Rewrite the predicate to avoid wrapping the column, e.g. use "WHERE col = @val OR col IS NULL" instead of "WHERE ISNULL(col, '') = @val".\nPredicate: isnull([StackOverflow2013].[dbo].[Posts].[LastEditorDisplayName] as [p].[LastEditorDisplayName],N'')=N'Jeff Atwood' + +### join_or_clause_plan.sqlplan +Wait: CXSYNC_PORT | Warning | CXSYNC_PORT Observed 33,039 ms across 9 waits. +Wait: PAGEIOLATCH_SH | Warning | PAGEIOLATCH_SH Observed 24,302 ms across 107,639 waits. Effective latency: 226 µs per wait. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Warning | RESERVED_MEMORY_ALLOCATION_EXT Observed 12,902 ms across 34,187,554 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 8,410 ms across 18,440,194 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 6,269 ms across 43,687 waits. +Wait: HTBUILD | Info | HTBUILD Observed 5,194 ms across 14 waits. +Wait: BPSORT | Info | BPSORT Observed 185 ms across 210 waits. +Wait: HTDELETE | Info | HTDELETE Observed 33 ms across 14 waits. +Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 1,440,204 of 1,440,301 rows discarded (100%)\n• 93,307,943 logical reads below\n• 1,783ms elapsed below\nPredicate: [Expr1002]>=(5000) +Nested Loops High Executions | Critical | Nested Loops inner side executed 17,091,572 times (DOP 8). Inner side total: 93,279,732 logical reads. Inner side time: 30,655ms (93% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(1) OR [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) +Nested Loops High Executions | Critical | Nested Loops inner side executed 30,700,008 times (DOP 8). Inner side total: 93,279,732 logical reads. Inner side time: 14,355ms (43% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Expensive Operator | Critical | Sort took 19,602ms (59.3% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Join OR Clause | Warning | OR in a join predicate. SQL Server rewrote the OR as 2 separate lookups, each evaluated independently — this multiplies the work on the inner side. Rewrite as separate queries joined with UNION ALL. For example, change "FROM a JOIN b ON a.x = b.x OR a.y = b.y" to "FROM a JOIN b ON a.x = b.x UNION ALL FROM a JOIN b ON a.y = b.y". +Expensive Operator | Warning | Clustered Index Seek took 14,355ms (43.4% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? + +### key_lookup_plan.sqlplan +Key Lookup | Critical | Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns.\nColumns fetched: Users.AboutMe, Users.Age, Users.CreationDate, Users.DisplayName, Users.DownVotes, Users.EmailHash, Users.LastAccessDate, Users.Location, Users.UpVotes, Users.Views, Users.WebsiteUrl, Users.AccountId\nResidual predicate (filtered 116 rows): [StackOverflow2013].[dbo].[Users].[DisplayName] as [u].[DisplayName]=N'Eggs McLaren'\nTo eliminate the lookup, consider adding the needed columns as INCLUDE columns on the nonclustered index. This widens the index, so weigh the read benefit against write and storage overhead. + +### lazy_spool_plan.sqlplan +Serial Plan | Warning | Query running serially: MAXDOP is set to 1 using a query hint. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 2,300 ms across 10,436 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 308 ms across 1,498,196 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 271 ms across 822,942 waits. +Wait: ASYNC_NETWORK_IO | Info | ASYNC_NETWORK_IO Observed 5 ms across 1 wait. +Wait: PAGEIOLATCH_EX | Info | PAGEIOLATCH_EX Observed 1 ms across 1 wait. Effective latency: 1.0 ms per wait. +Nested Loops High Executions | Critical | Nested Loops inner side executed 6,000,223 times (DOP 1). Inner side total: 59,711,931 logical reads. Inner side time: 37,690ms (85% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(1) +Expensive Operator | Warning | Lazy Index Spool took 12,029ms (27.3% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Eager Index Spool | Critical | SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely.\n\nCreate this index:\nCREATE INDEX [UserId] ON dbo.Badges (UserId) INCLUDE (Id, Date); +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 3 column(s): Badges.Id, Badges.UserId, Badges.Date. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### leading_wildcard_like_plan.sqlplan +Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 1 ms across 9 waits. +Wait: LATCH_EX | Info | LATCH_EX Observed 1 ms across 7 waits. +Non-SARGable Predicate | Warning | Leading wildcard LIKE prevents an index seek — SQL Server must scan every row. If substring search performance is critical, consider a full-text index or a trigram-based approach.\nPredicate: [StackOverflow2013].[dbo].[Users].[DisplayName] as [u].[DisplayName] like N'%zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzZZZ' +Scan Cardinality Misestimate | Critical | Estimated 103 rows but only 0 returned (0.000% of 2,465,713 rows read). The 103x overestimate likely caused the optimizer to choose a scan instead of a seek. An index on the predicate columns could dramatically reduce I/O. + +### local_variable_plan.sqlplan +Local Variables | Warning | Local variables detected: @date. SQL Server cannot sniff local variable values at compile time, so it uses average density estimates instead of your actual values. Test with OPTION (RECOMPILE) to see if the plan improves. For a permanent fix, use dynamic SQL or a stored procedure to pass the values as parameters instead of local variables. +Wait: LATCH_EX | Info | LATCH_EX Observed 15 ms across 106 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 4 ms across 1,329 waits. +Wait: HTBUILD | Info | HTBUILD Observed 4 ms across 14 waits. +Wait: HTDELETE | Info | HTDELETE Observed 1 ms across 14 waits. +Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 1 ms across 9 waits. +Scan With Predicate | Critical | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 100% of the plan cost. This scan took 100% of elapsed time. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[CreationDate]>=[@date] + +### many_to_many_merge_plan.sqlplan +Serial Plan | Warning | Query running serially: MAXDOP is set to 1 using a query hint. +Large Memory Grant | Critical | Query granted 5161 MB of memory. Largest consumers: Sort (Node 4, 3,756,598 actual rows), Sort (Node 2, 1 actual rows). +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 426 ms across 438,692 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 268 ms across 10,144 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 4 ms across 6,295 waits. +Many-to-Many Merge Join | Warning | Many-to-many Merge Join — SQL Server created a worktable in TempDB (11,174,351 logical reads) because both sides have duplicate values in the join columns. +Expensive Operator | Critical | Sort took 22,727ms (53.6% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 2 column(s): Votes.Id, Votes.VoteTypeId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### memory_grant_wait_plan.sqlplan +Memory Grant Wait | Warning | Query waited 1,000ms for a memory grant before it could start running. Other queries were using all available workspace memory. +Large Memory Grant | Critical | Query granted 10597 MB of memory. Largest consumers: Sort (Node 11, 7,385,957 actual rows), Sort (Node 1, 5,829 actual rows), Hash Match (Node 2, 5,829 actual rows). +Low Impact Index | Info | Missing index suggestion for Posts has only 10% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement. +Wait: CXSYNC_PORT | Critical | CXSYNC_PORT Observed 202,414 ms across 57 waits. +Wait: CXPACKET | Critical | CXPACKET Observed 132,945 ms across 1,160,672 waits. +Wait: LOGBUFFER | Warning | LOGBUFFER Observed 42,691 ms across 5,112 waits. +Wait: IO_COMPLETION | Critical | IO_COMPLETION Observed 27,534 ms across 164,349 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Warning | RESERVED_MEMORY_ALLOCATION_EXT Observed 5,444 ms across 1,280,982 waits. +Wait: SOS_PHYS_PAGE_CACHE | Warning | SOS_PHYS_PAGE_CACHE Observed 3,243 ms across 24,114 waits. +Wait: PAGEIOLATCH_EX | Info | PAGEIOLATCH_EX Observed 873 ms across 2 waits. Effective latency: 436 ms per wait. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 256 ms across 243,262 waits. +Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 225 ms across 690 waits. Effective latency: 326 µs per wait. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 83 ms across 1,268 waits. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.236% of rows survived filtering (5,829 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>=(20000) +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 (715 rows x 8 executions) — 12329x overestimated. The overestimate may have caused the optimizer to make poor choices. +Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 4,184,508 logical reads below\nPredicate: [Expr1002]=(1) +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 569,368 (71,171 rows x 8 executions) — 124x overestimated. The overestimate may have caused the optimizer to make poor choices. +Sort Spill | Critical | Sort spill level 1, 8 thread(s) — Granted: 10,815,552 KB, Used: 10,220,984 KB, Writes: 1,512,936, Reads: 1,512,936 Operator time: 25,148ms (94% of statement). +Expensive Operator | Warning | Parallelism took 12,362ms (46.1% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[Score] as [p].[Score]>(0) + +### mismatched_data_type_plan.sqlplan +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 2 ms across 1,168 waits. +Data Type Mismatch | Warning | Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column. +Expensive Operator | Critical | Index Seek took 3,760ms (80.4% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? + +### missing-join-predicate.sqlplan +Duplicate Index Suggestions | Warning | 2 missing index suggestions target Comments. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them. +No Join Predicate | Warning | 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. +Scan With Predicate | Critical | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 85% of the plan cost. This scan took 100% of elapsed time. Only 0.005% of rows survived filtering (1 of 19,787). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Comments].[UserId] as [c].[UserId]=(22656) + +### multi_index_delete_plan.sqlplan + +### multi_index_insert_plan.sqlplan + +### multi_index_update_plan.sqlplan +Expensive Operator | Critical | Clustered Index Update took 1ms (100.0% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? + +### non_sargable_function_plan.sqlplan +Wait: LATCH_EX | Info | LATCH_EX Observed 22 ms across 121 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 3 ms across 1,387 waits. +Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 2 ms across 9 waits. +Wait: HTBUILD | Info | HTBUILD Observed 2 ms across 14 waits. +Wait: HTDELETE | Info | HTDELETE Observed 1 ms across 14 waits. +Non-SARGable Predicate | Warning | Function call (DATEPART) on column prevents an index seek. Remove the function from the column side — apply it to the parameter instead, or create a computed column with the expression and index that.\nPredicate: datepart(year,[StackOverflow2013].[dbo].[Posts].[CreationDate])=(2013) + +### optimize_for_unknown_plan.sqlplan +Optimize For Unknown | Warning | OPTIMIZE FOR UNKNOWN uses average density estimates instead of sniffed parameter values. This can help when parameter sniffing causes plan instability, but may produce suboptimal plans for skewed data distributions. +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Users.Id. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### parallel-skew.sqlplan +Wide Index Suggestion | Warning | Missing index suggestion for Posts has 17 INCLUDE columns. This is a "kitchen sink" index — SQL Server suggests covering every column the query touches, but the resulting index would be very wide and expensive to maintain. Evaluate which columns are actually needed, or consider a narrower index with fewer includes. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 1 ms across 1,900 waits. +Wait: HTBUILD | Info | HTBUILD Observed 1 ms across 6 waits. +Wait: BMPBUILD | Info | BMPBUILD Observed 3 ms across 6 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 7 ms across 21,351 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 18 ms across 6,099 waits. +Wait: HTREPARTITION | Info | HTREPARTITION Observed 1,193 ms across 6 waits. +Wait: ASYNC_NETWORK_IO | Warning | ASYNC_NETWORK_IO Observed 1,358 ms across 17,777 waits. +Wait: HTDELETE | Warning | HTDELETE Observed 1,601 ms across 6 waits. +Wait: BPSORT | Warning | BPSORT Observed 4,974 ms across 607 waits. +Wait: CXPACKET | Warning | CXPACKET Observed 9,276 ms across 9,446 waits. +Parallel Skew | Info | Thread 1 processed 100% of rows (53,946/53,946). Work is heavily skewed to one thread, so parallelism isn't helping much. Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate. +Parallel Skew | Warning | Thread 3 processed 100% of rows (53,946/53,946). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Parallel Skew | Warning | Thread 4 processed 100% of rows (47,575/47,575). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 52% of the plan cost. This scan took 53% of elapsed time. Only 0.278% of rows survived filtering (47,575 of 17,142,169). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[CreationDate] as [p].[CreationDate]>='2013-12-25 00:00:00.000' +Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 52,874,774 of 52,928,720 rows discarded (100%)\n• 243,866 logical reads below\n• 1,719ms elapsed below\nPredicate: PROBE([Opt_Bitmap1006],[StackOverflow2013].[dbo].[Votes].[PostId] as [v].[PostId]) +Row Estimate Mismatch | Critical | Estimated 5,292,870 vs Actual 53,946 (13,486 rows x 4 executions) — 392x overestimated. The overestimate may have caused the optimizer to make poor choices. +Parallel Skew | Warning | Thread 3 processed 100% of rows (53,946/53,946). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Votes.PostId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. + +### param-sniffing-posttypeid2.sqlplan +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 1,051 ms across 2,052,049 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 6 ms across 4,481 waits. +Sort Spill | Critical | Sort spill level 8, 1 thread(s) — Granted: 17,384 KB, Used: 17,408 KB, Writes: 145,296, Reads: 145,296 Operator time: 94,518ms (86% of statement). +Row Estimate Mismatch | Critical | Estimated 681 vs Actual 37,332,131 — 54821x underestimated. The underestimate likely caused an insufficient memory grant, leading to a spill to TempDB. +Row Estimate Mismatch | Critical | Estimated 681 vs Actual 37,332,131 — 54821x underestimated. The underestimate may have caused the optimizer to make poor choices. +Nested Loops High Executions | Critical | Nested Loops inner side executed 37,332,131 times (DOP 1). Outer side: estimated 733 rows, actual 37,332,131 (50931x underestimate). The optimizer chose Nested Loops expecting far fewer iterations. Inner side time: 1,081ms (1% of statement). This may be caused by parameter sniffing — the optimizer chose Nested Loops based on a sniffed value that produced far fewer outer rows. +Row Estimate Mismatch | Critical | Estimated 733 vs Actual 37,332,131 — 50931x underestimated. The underestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Critical | Estimated 733 vs Actual 37,332,131 — 50931x underestimated. The underestimate may have caused the optimizer to make poor choices. +No Join Predicate | Warning | 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. + +### pspo-example.sqlplan +Wait: ASYNC_NETWORK_IO | Warning | ASYNC_NETWORK_IO Observed 187 ms across 19 waits. +Wait: PAGEIOLATCH_SH | Warning | PAGEIOLATCH_SH Observed 75 ms across 233 waits. Effective latency: 322 µs per wait. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 8 ms across 7,435 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 4 ms across 2,959 waits. +Key Lookup | Critical | Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns.\nColumns fetched: Posts.AcceptedAnswerId, Posts.AnswerCount, Posts.Body, Posts.ClosedDate, Posts.CommentCount, Posts.CommunityOwnedDate, Posts.CreationDate, Posts.FavoriteCount, Posts.LastActivityDate, Posts.LastEditDa...\nTo eliminate the lookup, consider adding the needed columns as INCLUDE columns on the nonclustered index. This widens the index, so weigh the read benefit against write and storage overhead. + +### rid_lookup_plan.sqlplan +RID Lookup | Warning | RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table. Predicate: [TestDB].[dbo].[HeapTable].[col2] > (10) + +### row-count-spool-slow.sqlplan +Wait: LATCH_EX | Info | LATCH_EX Observed 1 ms across 10 waits. +Wait: SOS_PHYS_PAGE_CACHE | Info | SOS_PHYS_PAGE_CACHE Observed 1 ms across 11 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 1 ms across 2,147 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 11 ms across 17,300 waits. +Wait: PAGELATCH_SH | Info | PAGELATCH_SH Observed 412 ms across 12,480 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 9,529 ms across 2,337,712 waits. +Wait: CXPACKET | Info | CXPACKET Observed 1,573,612 ms across 3,824 waits. +Hash Spill | Warning | Hash spill level 1, 6 thread(s) — Granted: 41,879,040 KB, Used: 9,923,968 KB, Writes: 188,928, Reads: 188,928 Operator time: 838ms (0% of statement). +Bare Scan | Warning | Heap table scan reads the full table with no predicate, outputting 1 column(s): #OldUsers.UserId. Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. +Nested Loops High Executions | Critical | Nested Loops inner side executed 24,198,656 times (DOP 6). Inner side total: 165,360 logical reads. Inner side time: 949ms (0% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Nested Loops High Executions | Critical | Nested Loops inner side executed 24,534,730 times (DOP 6). Inner side total: 24,534,730 logical reads. Inner side time: 1,563,621ms (100% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Bare Scan | Warning | Heap table scan reads the full table with no predicate, outputting 1 column(s): #NewUsers.UserId. Consider a clustered or nonclustered index on the output columns so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. +Top Above Scan | Critical | Top reads from Table Scan on tempdb.dbo.#OldUsers (Node 14). This is on the inner side of Nested Loops (Node 11), so the scan repeats for every outer row. An index on the ORDER BY columns could eliminate the scan and sort entirely. +Expensive Operator | Critical | Table Scan took 1,562,674ms (99.5% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +NOT IN with Nullable Column | Critical | Row Count Spool with 24,198,650 rewinds. This pattern occurs when NOT IN is used with a nullable column — SQL Server cannot use an efficient Anti Semi Join because it must check for NULL values on every outer row. Rewrite as NOT EXISTS, or add WHERE column IS NOT NULL to the subquery. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.000% of rows survived filtering (0 of 102,853,014). Check that you have appropriate indexes.\nPredicate: [tempdb].[dbo].[#OldUsers].[UserId] as [ou].[UserId] IS NULL + +### row_goal_plan.sqlplan +Top Above Scan | Warning | Top reads from Index Scan on TestDB.dbo.Users (Node 1). An index on the ORDER BY columns could eliminate the scan and sort entirely. +Row Goal | Info | Row goal active: estimate reduced from 2,500,000 to 1 (2,500,000x reduction) due to TOP. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal. + +### serially-parallel.sqlplan +Wait: CXSYNC_PORT | Critical | CXSYNC_PORT Observed 17,121 ms across 35 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Info | RESERVED_MEMORY_ALLOCATION_EXT Observed 65 ms across 82,739 waits. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 13 ms across 4,265 waits. +Wait: CXSYNC_CONSUMER | Critical | CXSYNC_CONSUMER Observed 2 ms across 14 waits. +Expensive Operator | Critical | Sort took 17,111ms (100.0% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Expensive Operator | Critical | Parallelism took 17,111ms (100.0% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Row Estimate Mismatch | Critical | Estimated 3,279 vs Actual 21 (3 rows x 8 executions) — 1249x overestimated. The overestimate may have caused the optimizer to make poor choices. +Parallel Skew | Warning | Thread 1 processed 100% of rows (27,026/27,026). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Nested Loops High Executions | Critical | Nested Loops inner side executed 10,810,787 times (DOP 8). Inner side total: 41,216,099 logical reads. Inner side time: 10,156ms (59% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Parallel Skew | Warning | Thread 1 processed 100% of rows (10,810,787/10,810,787). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Top Above Scan | Warning | Top reads from Index Scan on StackOverflow2013.dbo.Posts (Node 15). An index on the ORDER BY columns could eliminate the scan and sort entirely. +Parallel Skew | Warning | Thread 1 processed 100% of rows (10,810,787/10,810,787). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Parallel Skew | Warning | Thread 1 processed 100% of rows (27,026/27,026). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. +Key Lookup | Critical | Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns.\nResidual predicate (filtered 10,783,761 rows): [StackOverflow2013].[dbo].[Posts].[OwnerUserId] as [q].[OwnerUserId]=(22656)\nTo eliminate the lookup, consider adding the needed columns as INCLUDE columns on the nonclustered index. This widens the index, so weigh the read benefit against write and storage overhead. +Row Estimate Mismatch | Critical | Estimated 1 vs Actual 21 (0 rows x 27,026 executions) — 1287x overestimated. The overestimate may have caused the optimizer to make poor choices. + +### slow-multi-seek.sqlplan +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 199 ms across 37,744 waits. +Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 1 ms across 9 waits. +Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 28,425,793 of 28,425,793 rows discarded (100%)\n• 133,228,207 logical reads below\n• 19,811ms elapsed below\nPredicate: [Expr1002]=(0) +Row Estimate Mismatch | Warning | Estimated 36,946,200 vs Actual 28,425,793 (3,553,224 rows x 8 executions) — 10x overestimated. The overestimate may have caused the optimizer to make poor choices. +Nested Loops High Executions | Critical | Nested Loops inner side executed 11,091,349 times (DOP 8). Inner side total: 133,212,966 logical reads. Inner side time: 19,039ms (95% of statement). Consider whether a hash or merge join would be more appropriate for this row count. +Expensive Operator | Critical | Index Seek took 17,805ms (89.2% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? + +### spill_plan.sqlplan +Large Memory Grant | Critical | Query granted 10597 MB of memory. Largest consumers: Sort (Node 11, 7,385,957 actual rows), Sort (Node 1, 5,829 actual rows), Hash Match (Node 2, 5,829 actual rows). +Low Impact Index | Info | Missing index suggestion for Posts has only 10% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement. +Wait: CXSYNC_PORT | Critical | CXSYNC_PORT Observed 202,414 ms across 57 waits. +Wait: CXPACKET | Critical | CXPACKET Observed 132,945 ms across 1,160,672 waits. +Wait: LOGBUFFER | Warning | LOGBUFFER Observed 42,691 ms across 5,112 waits. +Wait: IO_COMPLETION | Critical | IO_COMPLETION Observed 27,534 ms across 164,349 waits. +Wait: RESERVED_MEMORY_ALLOCATION_EXT | Warning | RESERVED_MEMORY_ALLOCATION_EXT Observed 5,444 ms across 1,280,982 waits. +Wait: SOS_PHYS_PAGE_CACHE | Warning | SOS_PHYS_PAGE_CACHE Observed 3,243 ms across 24,114 waits. +Wait: PAGEIOLATCH_EX | Info | PAGEIOLATCH_EX Observed 873 ms across 2 waits. Effective latency: 436 ms per wait. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 256 ms across 243,262 waits. +Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 225 ms across 690 waits. Effective latency: 326 µs per wait. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 83 ms across 1,268 waits. +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.236% of rows survived filtering (5,829 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>=(20000) +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 (715 rows x 8 executions) — 12329x overestimated. The overestimate may have caused the optimizer to make poor choices. +Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 4,184,508 logical reads below\nPredicate: [Expr1002]=(1) +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 569,368 (71,171 rows x 8 executions) — 124x overestimated. The overestimate may have caused the optimizer to make poor choices. +Sort Spill | Critical | Sort spill level 1, 8 thread(s) — Granted: 10,815,552 KB, Used: 10,220,984 KB, Writes: 1,512,936, Reads: 1,512,936 Operator time: 25,148ms (94% of statement). +Expensive Operator | Warning | Parallelism took 12,362ms (46.1% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[Score] as [p].[Score]>(0) + +### table_variable_plan.sqlplan +Table Variable | Warning | Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table. +Table Variable | Warning | Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table. + +### top_above_scan_plan.sqlplan +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 3 ms across 1,269 waits. +Top Above Scan | Critical | Top reads from Clustered Index Scan on StackOverflow2013.dbo.Votes (Node 5). This is on the inner side of Nested Loops (Node 1), so the scan repeats for every outer row. The scan has a residual predicate, so it may read many rows before the Top is satisfied. An index on the ORDER BY columns could eliminate the scan and sort entirely. +Scan With Predicate | Critical | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 100% of the plan cost. This scan took 100% of elapsed time. Only 0.000% of rows survived filtering (0 of 52,928,720). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Votes].[PostId] as [v].[PostId]=[StackOverflow2013].[dbo].[Posts].[Id] as [p].[Id] + +### tvf_plan.sqlplan +Table-Valued Function | Warning | Table-valued function: dbo.GetTopPosts. Multi-statement TVFs have no statistics — SQL Server guesses 1 row (pre-2017) or 100 rows (2017+) regardless of actual size. Rewrite as an inline table-valued function if possible, or dump the function results into a #temp table and join to that instead. + +### udf_plan.sqlplan +Serial Plan | Warning | Query running serially: 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. +UDF Execution | Critical | Scalar UDF cost in this statement: 2,402ms elapsed, 2,296ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining. +Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 133 ms across 715 waits. +Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 15 ms across 40,676 waits. +Expensive Operator | Critical | Compute Scalar took 2,406ms (79.4% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be? +Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 70% of the plan cost. Only 0.236% of rows survived filtering (5,829 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>=(20000) + diff --git a/tests/PlanViewer.Core.Tests/WarningCharacterizationTests.cs b/tests/PlanViewer.Core.Tests/WarningCharacterizationTests.cs new file mode 100644 index 0000000..4f479ed --- /dev/null +++ b/tests/PlanViewer.Core.Tests/WarningCharacterizationTests.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; + +namespace PlanViewer.Core.Tests; + +/// +/// Golden-master characterization of the analyzer's complete warning output across +/// every committed test plan. Locks down the exact set, order, severity, and message +/// text of all warnings so structural refactors of PlanAnalyzer (e.g. extracting the +/// inline rules in AnalyzeNode/AnalyzeStatement into methods) can be proven behavior- +/// preserving. The baseline is recorded on first run into WarningBaseline.txt next to +/// this test; any later change in analyzer output fails the assertion. +/// +public class WarningCharacterizationTests +{ + [Fact] + public void AllPlans_WarningDigest_MatchesBaseline() + { + var plansDir = Path.Combine(AppContext.BaseDirectory, "Plans"); + var files = Directory.GetFiles(plansDir, "*.sqlplan") + .OrderBy(Path.GetFileName, StringComparer.Ordinal); + + var sb = new StringBuilder(); + foreach (var file in files) + { + var name = Path.GetFileName(file); + sb.Append("### ").Append(name).Append('\n'); + var plan = PlanTestHelper.LoadAndAnalyze(name); + foreach (var w in PlanTestHelper.AllWarnings(plan)) + { + var msg = (w.Message ?? string.Empty).Replace("\r\n", "\n").Replace("\n", "\\n"); + sb.Append(w.WarningType).Append(" | ").Append(w.Severity).Append(" | ").Append(msg).Append('\n'); + } + sb.Append('\n'); + } + var actual = sb.ToString().Replace("\r\n", "\n"); + + var baselinePath = Path.Combine(ProjectDir(), "WarningBaseline.txt"); + if (!File.Exists(baselinePath)) + { + File.WriteAllText(baselinePath, actual); + return; // first run records the baseline + } + + var expected = File.ReadAllText(baselinePath).Replace("\r\n", "\n"); + Assert.Equal(expected, actual); + } + + private static string ProjectDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "PlanViewer.Core.Tests.csproj"))) + dir = dir.Parent; + return dir?.FullName ?? AppContext.BaseDirectory; + } +} From 497c40369276af963269d81b98c6300e0eb9d55a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:40:57 -0400 Subject: [PATCH 12/16] Extract PlanAnalyzer inline rules into named methods AnalyzeNode (~770 lines, 25 inline rules) and AnalyzeStatement (~440, 12 rules) were two mega-methods. Turn each into a thin dispatcher that calls one named method per rule (Rule01_FilterOperator, Rule07_SpillSeverity, Rule38_StandardEditionDop, ...), preserving the exact execution order so ordering-dependent behavior is unchanged: - The spill-severity rule still runs in position and mutates the existing warning collection as before. - Rule 11 (scan residual) depends on Rule 12's non-SARGable determination; that shared local is now a pure GetNonSargableReason(node, cfg) helper both rules call, rather than a strategy registry (which the review warned against). No behavior change: the WarningBaseline golden master (full warning set across all 37 plans) and the per-rule tests all pass unchanged. 86 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/PlanAnalyzer.Node.cs | 137 +++++++++++++++++- .../Services/PlanAnalyzer.Statement.cs | 60 ++++++++ 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index 0b3ef69..49ed106 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -17,6 +17,35 @@ private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerC } private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { + Rule01_FilterOperator(node, stmt, cfg); + Rule02_EagerIndexSpool(node, stmt, cfg); + Rule04_UdfTiming(node, stmt, cfg); + Rule05_RowEstimateMismatch(node, stmt, cfg); + Rule06_ScalarUdfReference(node, stmt, cfg); + Rule07_SpillSeverity(node, stmt, cfg); + Rule08_ParallelThreadSkew(node, stmt, cfg); + Rule10_LookupResidual(node, stmt, cfg); + Rule12_NonSargablePredicate(node, stmt, cfg); + Rule11_ScanResidual(node, stmt, cfg); + Rule32_CardinalityMisestimateScan(node, stmt, cfg); + Rule33_CeGuessDetection(node, stmt, cfg); + Rule34_BareScanNarrowOutput(node, stmt, cfg); + Rule13_MismatchedDataTypes(node, stmt, cfg); + Rule14_LazyTableSpoolRebind(node, stmt, cfg); + Rule15_JoinOrClause(node, stmt, cfg); + Rule16_NestedLoopsHighExec(node, stmt, cfg); + Rule17_ManyToManyMerge(node, stmt, cfg); + Rule22_TableVariables(node, stmt, cfg); + Rule23_TableValuedFunctions(node, stmt, cfg); + Rule24_TopAboveScan(node, stmt, cfg); + Rule26_RowGoal(node, stmt, cfg); + Rule28_RowCountSpool(node, stmt, cfg); + Rule29_ImplicitConversionSeek(node, stmt, cfg); + Rule35_ExpensiveOperator(node, stmt, cfg); + } + + private static void Rule01_FilterOperator(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) { // Rule 1: Filter operators — rows survived the tree just to be discarded // Quantify the impact by summing child subtree cost (reads, CPU, time). @@ -58,6 +87,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi } } + } + + private static void Rule02_EagerIndexSpool(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" && node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase)) @@ -74,6 +107,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi }); } + } + + private static void Rule04_UdfTiming(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 4: UDF timing — any node spending time in UDFs (actual plans) if (!cfg.IsRuleDisabled(4) && (node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)) { @@ -85,6 +122,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi }); } + } + + private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 5: Large estimate vs actual row gaps (actual plans only) // Only warn when the bad estimate actually causes observable harm: // - The node itself spilled (Sort/Hash with bad memory grant) @@ -137,6 +178,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi } } + } + + private static void Rule06_ScalarUdfReference(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 6: Scalar UDF references (works on estimated plans too) // Suppress when Serial Plan warning is already firing for a UDF-related reason — // the Serial Plan warning already explains the issue, this would be redundant. @@ -156,6 +201,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi }); } + } + + private static void Rule07_SpillSeverity(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 7: Spill detection — calculate operator time and set severity // based on what percentage of statement elapsed time the spill accounts for. // Exchange spills on Parallelism operators get special handling since their @@ -208,6 +257,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi } } + } + + private static void Rule08_ParallelThreadSkew(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 8: Parallel thread skew (actual plans with per-thread stats) // Only warn when there are enough rows to meaningfully distribute across threads // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators @@ -253,6 +306,10 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi } } + } + + private static void Rule10_LookupResidual(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 10: Key Lookup / RID Lookup with residual predicate // Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true if (!cfg.IsRuleDisabled(10) && node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase)) @@ -295,10 +352,20 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi }); } + } + + // Shared by Rule 12 (emits the warning) and Rule 11 (which suppresses its residual- + // predicate warning when a non-SARGable predicate was already flagged). Pure function + // of node + cfg, so both rules can compute it independently. + private static string? GetNonSargableReason(PlanNode node, AnalyzerConfig cfg) => + cfg.IsRuleDisabled(12) || (node.HasActualStats && node.ActualExecutions == 0) + ? null : DetectNonSargablePredicate(node); + + private static void Rule12_NonSargablePredicate(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 12: Non-SARGable predicate on scan // Skip for 0-execution nodes — the operator never ran, so the warning is academic - var nonSargableReason = cfg.IsRuleDisabled(12) || (node.HasActualStats && node.ActualExecutions == 0) - ? null : DetectNonSargablePredicate(node); + var nonSargableReason = GetNonSargableReason(node, cfg); if (nonSargableReason != null) { var nonSargableAdvice = nonSargableReason switch @@ -325,10 +392,14 @@ _ when nonSargableReason.StartsWith("Function call") => }); } + } + + private static void Rule11_ScanResidual(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 11: Scan with residual predicate (skip if non-SARGable already flagged) // A PROBE() alone is just a bitmap filter — not a real residual predicate. // Skip for 0-execution nodes — the operator never ran - if (!cfg.IsRuleDisabled(11) && nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) && + if (!cfg.IsRuleDisabled(11) && GetNonSargableReason(node, cfg) == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) && !IsProbeOnly(node.Predicate) && !(node.HasActualStats && node.ActualExecutions == 0)) { var displayPredicate = StripProbeExpressions(node.Predicate); @@ -368,6 +439,10 @@ _ when nonSargableReason.StartsWith("Function call") => }); } + } + + private static void Rule32_CardinalityMisestimateScan(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 32: Cardinality misestimate on expensive scan — likely preventing index usage // When a scan dominates the plan AND the estimate is vastly higher than actual rows, // the optimizer chose a scan because it thought it needed most of the table. @@ -395,6 +470,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule33_CeGuessDetection(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity // When the optimizer uses a local variable or can't sniff, it falls back to density-based // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), @@ -421,6 +500,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule34_BareScanNarrowOutput(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 34: Bare scan with narrow output — NC index or columnstore candidate. // When a Clustered Index Scan or heap Table Scan reads the full table with no // predicate but only outputs a few columns, a narrower nonclustered index could @@ -472,6 +555,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule13_MismatchedDataTypes(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) if (!cfg.IsRuleDisabled(13) && node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) { @@ -493,6 +580,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule14_LazyTableSpoolRebind(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio // Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result) // Exclude Lazy Index Spools: they cache by correlated parameter value (like a hash table) @@ -523,6 +614,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule15_JoinOrClause(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 15: Join OR clause // Pattern: Nested Loops → Merge Interval → TopN Sort → [Compute Scalar] → Concatenation → [Compute Scalar] → 2+ Constant Scans if (!cfg.IsRuleDisabled(15) && node.PhysicalOp == "Concatenation") @@ -543,6 +638,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule16_NestedLoopsHighExec(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 16: Nested Loops high inner-side execution count // Deep analysis: combine execution count + outer estimate mismatch + inner cost if (!cfg.IsRuleDisabled(16) && node.PhysicalOp == "Nested Loops" && @@ -612,6 +711,10 @@ _ when nonSargableReason.StartsWith("Function call") => // deliberately — don't second-guess it without actual execution data. } + } + + private static void Rule17_ManyToManyMerge(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 17: Many-to-many Merge Join // In actual plans, the Merge Join operator reports logical reads when the worktable is used. // When ActualLogicalReads is 0, the worktable wasn't hit and the warning is noise. @@ -628,6 +731,10 @@ _ when nonSargableReason.StartsWith("Function call") => }); } + } + + private static void Rule22_TableVariables(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 22: Table variables (Object name starts with @) if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) @@ -646,6 +753,10 @@ _ when nonSargableReason.StartsWith("Function call") => }); } + } + + private static void Rule23_TableValuedFunctions(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 23: Table-valued functions if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") { @@ -658,6 +769,10 @@ _ when nonSargableReason.StartsWith("Function call") => }); } + } + + private static void Rule24_TopAboveScan(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 24: Top above a scan // Detects Top or Top N Sort operators feeding from a scan. This often means the // query is scanning the entire table/index and sorting just to return a few rows, @@ -696,6 +811,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule26_RowGoal(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN // Only surface on data access operators (seeks/scans) where the row goal actually matters var isDataAccess = node.PhysicalOp != null && @@ -734,6 +853,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule28_RowCountSpool(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 28: Row Count Spool — NOT IN with nullable column // Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate, // and statement text contains NOT IN @@ -751,6 +874,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule29_ImplicitConversionSeek(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 29: Enhance implicit conversion warnings — Seek Plan is more severe // Skip for 0-execution nodes — the operator never ran if (!cfg.IsRuleDisabled(29) && !(node.HasActualStats && node.ActualExecutions == 0)) @@ -763,6 +890,10 @@ _ when nonSargableReason.StartsWith("Function call") => } } + } + + private static void Rule35_ExpensiveOperator(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + { // Rule 35: Expensive Operator — always show operators that take a significant // share of statement time even when no other rule has something to say. Joe // (#215 C8) wanted expensive scans that the tool had nothing to suggest on diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index 2efb181..901e5ee 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -9,6 +9,22 @@ namespace PlanViewer.Core.Services; public static partial class PlanAnalyzer { private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata = null) + { + Rule03_SerialPlan(stmt, cfg, serverMetadata); + Rule09_MemoryGrant(stmt, cfg, serverMetadata); + Rule18_CompileMemoryExceeded(stmt, cfg, serverMetadata); + Rule19_HighCompileCpu(stmt, cfg, serverMetadata); + Rule04Stmt_UdfExecution(stmt, cfg, serverMetadata); + Rule20_LocalVariablesNoRecompile(stmt, cfg, serverMetadata); + Rule27_OptimizeForUnknown(stmt, cfg, serverMetadata); + Rule36_DynamicCursor(stmt, cfg, serverMetadata); + Rule37_CursorWithoutLocal(stmt, cfg, serverMetadata); + Rule38_StandardEditionDop(stmt, cfg, serverMetadata); + Rule30_MissingIndexQuality(stmt, cfg, serverMetadata); + Rule22Stmt_TableVariable(stmt, cfg, serverMetadata); + } + + private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) { // Rule 3: Serial plan with reason // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), @@ -124,6 +140,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser } } + } + + private static void Rule09_MemoryGrant(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 9: Memory grant issues (statement-level) if (!cfg.IsRuleDisabled(9) && stmt.MemoryGrant != null) { @@ -192,6 +212,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser } } + } + + private static void Rule18_CompileMemoryExceeded(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 18: Compile memory exceeded (early abort) if (!cfg.IsRuleDisabled(18) && stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") { @@ -203,6 +227,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser }); } + } + + private static void Rule19_HighCompileCpu(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 19: High compile CPU if (!cfg.IsRuleDisabled(19) && stmt.CompileCPUMs >= 1000) { @@ -214,6 +242,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser }); } + } + + private static void Rule04Stmt_UdfExecution(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 4 (statement-level): UDF execution timing from QueryTimeStats // Some plans report UDF timing only at the statement level, not per-node. if (!cfg.IsRuleDisabled(4) && (stmt.QueryUdfCpuTimeMs > 0 || stmt.QueryUdfElapsedTimeMs > 0)) @@ -226,6 +258,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser }); } + } + + private static void Rule20_LocalVariablesNoRecompile(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 20: Local variables without RECOMPILE // Parameters with no CompiledValue are likely local variables — the optimizer // cannot sniff their values and uses density-based ("unknown") estimates. @@ -256,6 +292,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser // for actual plans, SQL Server runtime stats show exactly where time was // spent, so a statement-text-pattern warning about CTE reuse is guessing. + } + + private static void Rule27_OptimizeForUnknown(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) && Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) @@ -268,6 +308,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser }); } + } + + private static void Rule36_DynamicCursor(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 36: Dynamic cursor (#215 E1). Dynamic cursors can prevent index usage // because they must tolerate underlying data changes between fetches, forcing // scans and extra work per fetch. Switching to FAST_FORWARD, STATIC, or KEYSET @@ -284,6 +328,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser }); } + } + + private static void Rule37_CursorWithoutLocal(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 37: CURSOR declaration without LOCAL (#215 E3). Default cursor scope // is GLOBAL in SQL Server, which puts cursors in a shared namespace and can // bloat the plan cache (Erik's writeup: @@ -315,6 +363,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser } } + } + + private static void Rule38_StandardEditionDop(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 38: Standard Edition DOP 2 limitation with batch mode // SQL Server Standard Edition limits DOP to 2 when batch mode operators are present. if (!cfg.IsRuleDisabled(38) && stmt.DegreeOfParallelism == 2 && stmt.RootNode != null @@ -359,6 +411,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser // The CPU:Elapsed ratio is now shown in the runtime summary, and wait stats speak // for themselves — no need for meta-warnings guessing at causes. + } + + private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 30: Missing index quality evaluation if (!cfg.IsRuleDisabled(30)) { @@ -421,6 +477,10 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser } } + } + + private static void Rule22Stmt_TableVariable(PlanStatement stmt, AnalyzerConfig cfg, ServerMetadata? serverMetadata) + { // Rule 22 (statement-level): Table variable warnings // Walk the tree to find table variable references, then emit statement-level warnings if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null) From 0cd3ac1b7fffb14a94610e470a6c4a206c781ac1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:32:22 -0400 Subject: [PATCH 13/16] Preserve Unix execute bit in Linux/macOS release zips Compress-Archive on the windows-latest runner can't store Unix file-mode bits, so the self-contained apphost (PlanViewer.App) extracted as non-executable on Linux/macOS and the app would not launch. .NET's ZipArchive is no better: on Windows it stamps the archive host byte as Windows (create_system=0), so unzip/macOS ignore the mode even when ExternalAttributes carries 0755. Add .github/scripts/zip_with_exec.py (Python zipfile with create_system=3) to zip the Linux flat output and both macOS .app bundles, marking the apphost (and createdump if present) 0755. It hard-fails if the apphost is missing so a broken archive fails the release loudly. Wire it into release.yml and nightly.yml; Windows stays on Compress-Archive (no Unix bit needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/zip_with_exec.py | 119 +++++++++++++++++++++++++++++++ .github/workflows/nightly.yml | 21 ++++-- .github/workflows/release.yml | 24 +++++-- 3 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/zip_with_exec.py diff --git a/.github/scripts/zip_with_exec.py b/.github/scripts/zip_with_exec.py new file mode 100644 index 0000000..69db3f9 --- /dev/null +++ b/.github/scripts/zip_with_exec.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Create a .zip whose entries carry Unix permission bits. + +Why this exists +--------------- +The release/ nightly workflows run on windows-latest, where PowerShell's +Compress-Archive cannot store Unix file modes. The .NET ZipArchive API can set +external attributes but hardcodes the archive's "host system" byte to Windows +(0), so unzip / macOS / Linux ignore the mode and the self-contained .NET +apphost (PlanViewer.App) extracts WITHOUT its execute bit -- the app then fails +to launch on Linux/macOS ("permission denied"). + +Python's zipfile lets us set create_system = 3 (Unix) explicitly, so extractors +honor the mode. Files default to 0644, directories to 0755, and any path passed +via --exec / --exec-optional is marked 0755 (rwxr-xr-x). + +Usage +----- + python zip_with_exec.py SOURCE_DIR DEST_ZIP + [--exec RELPATH ...] [--exec-optional RELPATH ...] + + SOURCE_DIR directory whose *contents* are zipped (the directory itself + is not stored as a top-level entry -- matches the behavior of + `Compress-Archive -Path SOURCE_DIR/*`) + DEST_ZIP output .zip path (overwritten if present) + --exec forward-slash relpath that MUST exist; marked executable. + A missing --exec path is a hard error so a renamed/absent + apphost fails the release loudly instead of shipping a zip + that won't launch. + --exec-optional relpath marked executable if present, skipped if absent + (e.g. createdump, which only some runtime IDs include). +""" +import argparse +import os +import shutil +import stat +import sys +import zipfile + +UNIX = 3 # ZIP "version made by" host system: 3 = Unix (so the high word of + # external_attr is read as st_mode by unzip / macOS / Linux). + + +def main(): + ap = argparse.ArgumentParser(description="Zip a directory preserving Unix exec bits.") + ap.add_argument("source_dir") + ap.add_argument("dest_zip") + ap.add_argument("--exec", action="append", default=[], dest="execs", + metavar="RELPATH", help="path that must exist; marked 0755") + ap.add_argument("--exec-optional", action="append", default=[], dest="execs_optional", + metavar="RELPATH", help="path marked 0755 if present") + args = ap.parse_args() + + source = os.path.abspath(args.source_dir) + if not os.path.isdir(source): + sys.exit(f"error: source dir not found: {source}") + + required = {p.replace("\\", "/").strip("/") for p in args.execs} + optional = {p.replace("\\", "/").strip("/") for p in args.execs_optional} + + # Walk first so we can validate that every required exec path is present + # before we start writing the archive. + dir_entries = [] # arcname with trailing slash + file_entries = [] # (abs_path, arcname) + present = set() + for root, dirs, files in os.walk(source): + dirs.sort() + files.sort() + rel_root = os.path.relpath(root, source) + if rel_root != ".": + dir_entries.append(rel_root.replace("\\", "/") + "/") + for fn in files: + abs_path = os.path.join(root, fn) + arc = os.path.relpath(abs_path, source).replace("\\", "/") + file_entries.append((abs_path, arc)) + present.add(arc) + + missing = sorted(required - present) + if missing: + sys.exit("error: required --exec path(s) not found under {}: {}".format( + source, ", ".join(missing))) + + exec_paths = set(required) | (set(optional) & present) + + dest = os.path.abspath(args.dest_zip) + os.makedirs(os.path.dirname(dest), exist_ok=True) + if os.path.exists(dest): + os.remove(dest) + + marked = [] + with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for dir_arc in dir_entries: + zi = zipfile.ZipInfo(dir_arc) + zi.create_system = UNIX + # S_IFDIR | 0755 in the Unix high word; 0x10 = FILE_ATTRIBUTE_DIRECTORY + # in the DOS low word so Windows tools also see it as a directory. + zi.external_attr = ((stat.S_IFDIR | 0o755) << 16) | 0x10 + zf.writestr(zi, b"") + + for abs_path, arc in file_entries: + zi = zipfile.ZipInfo.from_file(abs_path, arc) # carries file mtime + zi.create_system = UNIX + zi.compress_type = zipfile.ZIP_DEFLATED + if arc in exec_paths: + zi.external_attr = (stat.S_IFREG | 0o755) << 16 + marked.append(arc) + else: + zi.external_attr = (stat.S_IFREG | 0o644) << 16 + with open(abs_path, "rb") as src, zf.open(zi, "w") as dst: + shutil.copyfileobj(src, dst) + + print(f"Created {dest}") + print(f" {len(file_entries)} file(s), {len(dir_entries)} dir(s)") + print(f" executable: {', '.join(sorted(marked)) if marked else '(none)'}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1580355..3fc4fa5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -81,12 +81,17 @@ jobs: run: | New-Item -ItemType Directory -Force -Path releases - # Package Windows and Linux as flat zips - foreach ($rid in @('win-x64', 'linux-x64')) { - if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" } - Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force - } + # Windows: flat zip via Compress-Archive (no Unix execute bit needed). + if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/win-x64/' } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/win-x64/' } + Compress-Archive -Path 'publish/win-x64/*' -DestinationPath "releases/PerformanceStudio-win-x64-$env:VERSION.zip" -Force + + # Linux: flat zip via zip_with_exec.py so the apphost keeps its execute + # bit. Compress-Archive stamps the archive host byte as Windows, so the + # Unix mode is dropped and the binary extracts non-executable. + if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/linux-x64/' } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/linux-x64/' } + python .github/scripts/zip_with_exec.py publish/linux-x64 "releases/PerformanceStudio-linux-x64-$env:VERSION.zip" --exec PlanViewer.App --exec-optional createdump # Package macOS as proper .app bundles foreach ($rid in @('osx-x64', 'osx-arm64')) { @@ -115,7 +120,9 @@ jobs: if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" } if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" } - Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force + # Zip the bundle with zip_with_exec.py so the apphost inside the .app + # keeps its execute bit (Compress-Archive would strip it). + python .github/scripts/zip_with_exec.py "$wrapperDir" "releases/PerformanceStudio-$rid-$env:VERSION.zip" --exec "PerformanceStudio.app/Contents/MacOS/PlanViewer.App" --exec-optional "PerformanceStudio.app/Contents/MacOS/createdump" } - name: Generate checksums diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db5f207..608c77d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,12 +193,19 @@ jobs: run: | New-Item -ItemType Directory -Force -Path releases - # Package Windows (signed) and Linux as flat zips - foreach ($rid in @('win-x64', 'linux-x64')) { - if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" } - Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force - } + # Windows (signed): flat zip via Compress-Archive. Windows has no Unix + # execute bit, so the signed .exe needs no special handling. + if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/win-x64/' } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/win-x64/' } + Compress-Archive -Path 'publish/win-x64/*' -DestinationPath 'releases/PerformanceStudio-win-x64.zip' -Force + + # Linux: flat zip via zip_with_exec.py so the apphost keeps its execute + # bit. Compress-Archive (and .NET's ZipArchive) stamp the archive's host + # byte as Windows, so unzip/macOS/Linux drop the Unix mode and the binary + # extracts non-executable -- it then won't launch ("permission denied"). + if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/linux-x64/' } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/linux-x64/' } + python .github/scripts/zip_with_exec.py publish/linux-x64 releases/PerformanceStudio-linux-x64.zip --exec PlanViewer.App --exec-optional createdump # Package macOS as proper .app bundles foreach ($rid in @('osx-x64', 'osx-arm64')) { @@ -233,7 +240,10 @@ jobs: if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" } if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" } - Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force + # Zip the bundle with zip_with_exec.py so the apphost inside the .app + # keeps its execute bit (Compress-Archive would strip it, leaving a + # bundle macOS refuses to launch). + python .github/scripts/zip_with_exec.py "$wrapperDir" "releases/PerformanceStudio-$rid.zip" --exec "PerformanceStudio.app/Contents/MacOS/PlanViewer.App" --exec-optional "PerformanceStudio.app/Contents/MacOS/createdump" } # Checksums (zips only, Velopack has its own checksums) From 70cf90a47fa716bb9b2b626e5762b5096638f0b6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:32:43 -0400 Subject: [PATCH 14/16] Register .sqlplan document type in macOS app bundle Add CFBundleDocumentTypes to Info.plist so Finder shows the app icon for .sqlplan files and routes them to Performance Studio via the Open With menu. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.App/Info.plist | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/PlanViewer.App/Info.plist b/src/PlanViewer.App/Info.plist index 4599f54..eb51344 100644 --- a/src/PlanViewer.App/Info.plist +++ b/src/PlanViewer.App/Info.plist @@ -22,5 +22,20 @@ EDD.icns NSHighResolutionCapable + CFBundleDocumentTypes + + + CFBundleTypeName + SQL Server Execution Plan + CFBundleTypeExtensions + + sqlplan + + CFBundleTypeRole + Editor + CFBundleTypeIconFile + EDD.icns + + From 7f6d6a9987a789e6cd92412c8b208caa26c5f5d9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:32:43 -0400 Subject: [PATCH 15/16] Bump version to 1.14.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2d49453..b3bafa0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.13.0 + 1.14.0 Erik Darling Darling Data LLC Performance Studio From 582c88e214bf6a5e892fa70f4d27b1cf543ae216 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:44:11 -0400 Subject: [PATCH 16/16] Fix culture-dependent percent formatting in analyzer warnings The ":P0" format is culture-sensitive in the space before the percent sign (en-US -> "100%", InvariantCulture -> "100 %"). Warning text therefore differed between en-US dev machines and CI runners that default to InvariantCulture, breaking the golden-master WarningCharacterizationTests (baseline recorded "100%", ubuntu produced "100 %"). Format the three percents as "{x * 100:N0}%" with a literal percent sign. N0 preserves the digits and group separators :P0 produced on en-US, so the committed baseline stays valid; the literal % drops the culture-dependent space, making output identical on every platform. Verified: full Core suite passes under default and DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index 49ed106..ddc11dd 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -234,7 +234,7 @@ private static void Rule07_SpillSeverity(PlanNode node, PlanStatement stmt, Anal if (stmtMs > 0 && operatorMs > 0) { var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct * 100:N0}% of statement)."; } } } @@ -247,7 +247,7 @@ private static void Rule07_SpillSeverity(PlanNode node, PlanStatement stmt, Anal if (stmtMs > 0) { var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct:P0} of statement)."; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct * 100:N0}% of statement)."; if (pct >= 0.5) w.Severity = PlanWarningSeverity.Critical; @@ -278,7 +278,7 @@ private static void Rule08_ParallelThreadSkew(PlanNode node, PlanStatement stmt, var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50; if (skewRatio >= skewThreshold) { - var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; + var message = $"Thread {maxThread.ThreadId} processed {skewRatio * 100:N0}% of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much."; var severity = PlanWarningSeverity.Warning; // Batch mode sorts produce all output on a single thread by design